1
0
mirror of https://github.com/IBM/fp-go.git synced 2026-04-09 15:26:02 +02:00

Compare commits

...

70 Commits

Author SHA1 Message Date
Dr. Carsten Leue
21b517d388 fix: better doc and NonEmptyString
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-04-09 14:51:39 +02:00
Dr. Carsten Leue
0df62c0031 fix: unroll array Fold and FoldMap
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-04-09 11:11:00 +02:00
Dr. Carsten Leue
57318e2d1d fix: make use of Empty lazy
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-04-08 15:00:39 +02:00
Dr. Carsten Leue
2b937d3e93 doc: add marble diagrams
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-30 10:15:27 +02:00
Dr. Carsten Leue
747a1794e5 fix: add more iter operators
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-30 10:04:20 +02:00
renovate[bot]
c754cacf1f fix(deps): update module github.com/urfave/cli/v3 to v3.8.0 (#159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 20:40:07 +00:00
Dr. Carsten Leue
d357b32847 fix: add TapThunkK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-23 18:43:49 +01:00
Dr. Carsten Leue
a3af003e74 fix: undo Pipe and Flow changes, did not have the desired effect
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-20 23:20:07 +01:00
Dr. Carsten Leue
c81235827b fix: try to change the way Pipe and Flow are structured
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-20 12:05:25 +01:00
Dr. Carsten Leue
f35430cf18 fix: introduce async iterators
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-18 10:19:03 +01:00
Dr. Carsten Leue
d3ffc71808 fix: add ModifyF
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-17 15:23:10 +01:00
Dr. Carsten Leue
62844b7030 fix: add Filter and FilterMap
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-15 23:33:08 +01:00
Dr. Carsten Leue
99a0ddd4b6 fix: implement filter and filtermap
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-15 23:18:14 +01:00
Dr. Carsten Leue
02acbae8f6 fix: add lenses for Hostname and Port
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-15 22:49:11 +01:00
Dr. Carsten Leue
eb27ecdc01 fix: clarify behaviour of array.Concat
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-13 18:39:55 +01:00
Dr. Carsten Leue
e5eb7d343c fix: add inline flags
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-13 09:26:39 +01:00
Dr. Carsten Leue
d5a3217251 fix: add FromIso
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-12 22:38:26 +01:00
Dr. Carsten Leue
c5cbdaad68 fix: add FromIso
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-12 22:38:01 +01:00
Dr. Carsten Leue
5d0f27ad10 fix: add SequenceSeq and TraverseSeq
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-12 20:38:52 +01:00
Dr. Carsten Leue
3a954e0d1f fix: introduce Promap for Effect
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-10 16:10:12 +01:00
Dr. Carsten Leue
cb2e0b23e8 fix: improve docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-09 20:41:56 +01:00
Dr. Carsten Leue
8d5dc7ea1f fix: increase test coverage
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 23:52:08 +01:00
Dr. Carsten Leue
69a11bc681 fix: increase test coverage
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 23:51:44 +01:00
Dr. Carsten Leue
a0910b8279 fix: add -coverpkg=./... to v2
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 23:34:17 +01:00
Dr. Carsten Leue
029d7be52d fix: better collection of coverage results
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 23:32:14 +01:00
Dr. Carsten Leue
c6d30bb642 fix: increase test timeout
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 23:21:27 +01:00
Dr. Carsten Leue
1821f00fbe fix: introduce effect.LocalReaderK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 22:52:20 +01:00
Dr. Carsten Leue
f0ec0b2541 fix: optimize record performance
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 22:20:19 +01:00
Dr. Carsten Leue
ce3c7d9359 fix: documentation of endomorphism
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 22:02:11 +01:00
Dr. Carsten Leue
3ed354cc8c fix: implement endomorphism.Read
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 19:01:22 +01:00
Dr. Carsten Leue
0932c8c464 fix: add tests for totality and move skills
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-08 14:12:41 +01:00
Dr. Carsten Leue
475d09e987 fix: add skills
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-07 22:39:33 +01:00
Dr. Carsten Leue
fd21bdeabf fix: signature of local for context/readerresult
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-07 22:03:17 +01:00
Dr. Carsten Leue
6834f72856 fix: make signature of Local for context more generic, but backwards compatible
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-07 21:02:24 +01:00
Dr. Carsten Leue
8cfb7ef659 fix: logging implementation for context sensitive operations
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 23:54:42 +01:00
Dr. Carsten Leue
622c87d734 fix: logging implementation for context sensitive operations
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 23:50:54 +01:00
Dr. Carsten Leue
2ce406a410 fix: add context7 badge
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 15:03:05 +01:00
Dr. Carsten Leue
3743361b9f fix: context7 at correct location
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 14:41:47 +01:00
Dr. Carsten Leue
69d11f0a4b fix: claim context7
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 14:39:58 +01:00
Dr. Carsten Leue
e4dd1169c4 fix: recursion in Errors()
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-04 11:12:22 +01:00
Dr. Carsten Leue
1657569f1d Merge branch 'main' of github.com:IBM/fp-go 2026-03-04 10:31:04 +01:00
Dr. Carsten Leue
545876d013 fix: add bool codec
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-04 10:30:55 +01:00
renovate[bot]
9492c5d994 chore(deps): update actions/setup-node action to v6.3.0 (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 09:20:04 +00:00
Dr. Carsten Leue
94b1ea30d1 fix: improved doc
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-02 13:23:59 +01:00
Dr. Carsten Leue
a77d61f632 fix: add Bind for Codec
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-02 13:01:21 +01:00
renovate[bot]
66b2f57d73 fix(deps): update module github.com/urfave/cli/v3 to v3.7.0 (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 08:40:21 +00:00
Dr. Carsten Leue
92eb2a50a2 fix: support MarshalJSON and MarshalText types
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-01 22:38:20 +01:00
Dr. Carsten Leue
3df1dca146 fix: better result type for Pipe
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 17:03:12 +01:00
Dr. Carsten Leue
a0132e2e92 fix: parameter order
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 15:08:11 +01:00
Dr. Carsten Leue
c6b342908d doc: better explanation for logger
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 13:52:08 +01:00
Dr. Carsten Leue
962237492f doc: explain Effect
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 13:27:20 +01:00
Dr. Carsten Leue
168a6e1072 fix: add Eitherize to Effect
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 12:55:03 +01:00
Dr. Carsten Leue
4d67b1d254 fix: expose Empty for Codec
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-27 10:52:23 +01:00
Dr. Carsten Leue
77a8cc6b09 fix: implement ApSO
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-26 18:44:27 +01:00
Dr. Carsten Leue
bc8743fdfc fix: build error
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-26 18:21:37 +01:00
Dr. Carsten Leue
1837d3f86d fix: add semigroup helpers
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-26 16:39:05 +01:00
Dr. Carsten Leue
b2d111e8ec fix: more doc and tests
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-26 14:04:44 +01:00
Dr. Carsten Leue
ae141c85c6 fix: add tests
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-26 13:40:12 +01:00
Dr. Carsten Leue
1230b4581b doc: add doc links
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-26 10:12:20 +01:00
Dr. Carsten Leue
70c831c8f9 fix: simplify type arguments
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-25 16:27:21 +01:00
Dr. Carsten Leue
cc0c14c7cf fix: do not fail if coverage fails
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-25 11:35:16 +01:00
Dr. Carsten Leue
19159ad49e fix: WriteFile
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-25 10:42:46 +01:00
Dr. Carsten Leue
b9c8fb4ff1 fix: parameter order for Local and TapIOK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-24 17:55:32 +01:00
Dr. Carsten Leue
4529e720bc fix: add ChainThunkK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-24 15:59:51 +01:00
Dr. Carsten Leue
ef8b2ea65d fix: add ChainReaderK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-24 15:31:51 +01:00
Dr. Carsten Leue
5bd7caafdd fix: better doc
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-24 13:42:29 +01:00
Dr. Carsten Leue
47ebcd79b1 fix: add LocalIOResultK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-23 14:15:00 +01:00
Dr. Carsten Leue
dbad94806e fix: add LocalIOResultK
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-23 14:14:21 +01:00
Dr. Carsten Leue
c4cac1cb3e fix: add FromReaderResult
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-21 16:44:51 +01:00
Dr. Carsten Leue
a3fdb03df4 fix: better assertions
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-02-13 09:27:49 +01:00
208 changed files with 37161 additions and 2864 deletions

View File

@@ -39,9 +39,10 @@ jobs:
- name: Run tests
run: |
go mod tidy
go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
go test -race -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... ./...
- name: Upload coverage to Coveralls
continue-on-error: true
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -78,9 +79,10 @@ jobs:
run: |
cd v2
go mod tidy
go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
go test -race -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... ./...
- name: Upload coverage to Coveralls
continue-on-error: true
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -106,6 +108,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Coveralls Finished
continue-on-error: true
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -131,7 +134,7 @@ jobs:
fetch-depth: 0
- name: Set up Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ env.NODE_VERSION }}

4
context7.json Normal file
View File

@@ -0,0 +1,4 @@
{
"url": "https://context7.com/ibm/fp-go",
"public_key": "pk_7wJdJRn8zGHxvIYu7eh9h"
}

318
skills/fp-go-http/SKILL.md Normal file
View File

@@ -0,0 +1,318 @@
# fp-go HTTP Requests
## Overview
fp-go wraps `net/http` in the `ReaderIOResult` monad, giving you composable, context-aware HTTP operations with automatic error propagation. The core package is:
```
github.com/IBM/fp-go/v2/context/readerioresult/http
```
All HTTP operations are lazy — they describe what to do but do not execute until you call the resulting function with a `context.Context`.
## Core Types
```go
// Requester builds an *http.Request given a context.
type Requester = ReaderIOResult[*http.Request] // func(context.Context) func() result.Result[*http.Request]
// Client executes a Requester and returns the response wrapped in ReaderIOResult.
type Client interface {
Do(Requester) ReaderIOResult[*http.Response]
}
```
## Basic Usage
### 1. Create a Client
```go
import (
HTTP "net/http"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
)
client := H.MakeClient(HTTP.DefaultClient)
// Or with a custom client:
custom := &HTTP.Client{Timeout: 10 * time.Second}
client := H.MakeClient(custom)
```
### 2. Build a Request
```go
// GET request (most common)
req := H.MakeGetRequest("https://api.example.com/users/1")
// Arbitrary method + body
req := H.MakeRequest("POST", "https://api.example.com/users", bodyReader)
```
### 3. Execute and Parse
```go
import (
"context"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
client := H.MakeClient(HTTP.DefaultClient)
// ReadJSON validates status, Content-Type, then unmarshals JSON
result := H.ReadJSON[User](client)(H.MakeGetRequest("https://api.example.com/users/1"))
// Execute — provide context once
user, err := result(context.Background())()
```
## Response Readers
All accept a `Client` and return a function `Requester → ReaderIOResult[A]`:
| Function | Returns | Notes |
|----------|---------|-------|
| `ReadJSON[A](client)` | `ReaderIOResult[A]` | Validates status + Content-Type, unmarshals JSON |
| `ReadText(client)` | `ReaderIOResult[string]` | Validates status, reads body as UTF-8 string |
| `ReadAll(client)` | `ReaderIOResult[[]byte]` | Validates status, returns raw body bytes |
| `ReadFullResponse(client)` | `ReaderIOResult[FullResponse]` | Returns `Pair[*http.Response, []byte]` |
`FullResponse = Pair[*http.Response, []byte]` — use `pair.First` / `pair.Second` to access components.
## Composing Requests in Pipelines
```go
import (
F "github.com/IBM/fp-go/v2/function"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
)
client := H.MakeClient(HTTP.DefaultClient)
readPost := H.ReadJSON[Post](client)
pipeline := F.Pipe2(
H.MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1"),
readPost,
RIO.ChainFirstIOK(IO.Logf[Post]("Got post: %v")),
)
post, err := pipeline(context.Background())()
```
## Parallel Requests — Homogeneous Types
Use `RIO.TraverseArray` when all requests return the same type:
```go
import (
A "github.com/IBM/fp-go/v2/array"
F "github.com/IBM/fp-go/v2/function"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
)
type PostItem struct {
UserID uint `json:"userId"`
ID uint `json:"id"`
Title string `json:"title"`
}
client := H.MakeClient(HTTP.DefaultClient)
readPost := H.ReadJSON[PostItem](client)
// Fetch 10 posts in parallel
data := F.Pipe3(
A.MakeBy(10, func(i int) string {
return fmt.Sprintf("https://jsonplaceholder.typicode.com/posts/%d", i+1)
}),
RIO.TraverseArray(F.Flow3(
H.MakeGetRequest,
readPost,
RIO.ChainFirstIOK(IO.Logf[PostItem]("Post: %v")),
)),
RIO.ChainFirstIOK(IO.Logf[[]PostItem]("All posts: %v")),
RIO.Map(A.Size[PostItem]),
)
count, err := data(context.Background())()
```
## Parallel Requests — Heterogeneous Types
Use `RIO.TraverseTuple2` (or `Tuple3`, etc.) when requests return different types:
```go
import (
T "github.com/IBM/fp-go/v2/tuple"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
F "github.com/IBM/fp-go/v2/function"
)
type CatFact struct {
Fact string `json:"fact"`
}
client := H.MakeClient(HTTP.DefaultClient)
readPost := H.ReadJSON[PostItem](client)
readCatFact := H.ReadJSON[CatFact](client)
// Execute both requests in parallel with different response types
data := F.Pipe3(
T.MakeTuple2(
"https://jsonplaceholder.typicode.com/posts/1",
"https://catfact.ninja/fact",
),
T.Map2(H.MakeGetRequest, H.MakeGetRequest), // build both requesters
RIO.TraverseTuple2(readPost, readCatFact), // run in parallel, typed
RIO.ChainFirstIOK(IO.Logf[T.Tuple2[PostItem, CatFact]]("Result: %v")),
)
both, err := data(context.Background())()
// both.F1 is PostItem, both.F2 is CatFact
```
## Building Requests with the Builder API
For complex requests (custom headers, query params, JSON body), use the builder:
```go
import (
B "github.com/IBM/fp-go/v2/http/builder"
RB "github.com/IBM/fp-go/v2/context/readerioresult/http/builder"
F "github.com/IBM/fp-go/v2/function"
)
// GET with query parameters
req := F.Pipe2(
B.Default,
B.WithURL("https://api.example.com/items?page=1"),
B.WithQueryArg("limit")("50"),
)
requester := RB.Requester(req)
// POST with JSON body
req := F.Pipe3(
B.Default,
B.WithURL("https://api.example.com/users"),
B.WithMethod("POST"),
B.WithJSON(map[string]string{"name": "Alice"}),
// sets Content-Type: application/json automatically
)
requester := RB.Requester(req)
// With authentication and custom headers
req := F.Pipe3(
B.Default,
B.WithURL("https://api.example.com/protected"),
B.WithBearer("my-token"), // sets Authorization: Bearer my-token
B.WithHeader("X-Request-ID")("123"),
)
requester := RB.Requester(req)
// Execute
result := H.ReadJSON[Response](client)(requester)
data, err := result(ctx)()
```
### Builder Functions
| Function | Effect |
|----------|--------|
| `B.WithURL(url)` | Set the target URL |
| `B.WithMethod(method)` | Set HTTP method (GET, POST, PUT, DELETE, …) |
| `B.WithJSON(v)` | Marshal `v` as JSON body, set `Content-Type: application/json` |
| `B.WithBytes(data)` | Set raw bytes body, set `Content-Length` automatically |
| `B.WithHeader(key)(value)` | Add a request header |
| `B.WithBearer(token)` | Set `Authorization: Bearer <token>` |
| `B.WithQueryArg(key)(value)` | Append a query parameter |
## Error Handling
Errors from request creation, HTTP status codes, Content-Type validation, and JSON parsing all propagate automatically through the `Result` monad. You only handle errors at the call site:
```go
// Pattern 1: direct extraction
value, err := pipeline(ctx)()
if err != nil { /* handle */ }
// Pattern 2: Fold for clean HTTP handler
RIO.Fold(
func(err error) { http.Error(w, err.Error(), http.StatusInternalServerError) },
func(data MyType) { json.NewEncoder(w).Encode(data) },
)(pipeline)(ctx)()
```
## Full HTTP Handler Example
```go
package main
import (
"context"
"encoding/json"
"net/http"
HTTP "net/http"
"fmt"
F "github.com/IBM/fp-go/v2/function"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
)
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
}
var client = H.MakeClient(HTTP.DefaultClient)
func fetchPost(id int) RIO.ReaderIOResult[Post] {
url := fmt.Sprintf("https://jsonplaceholder.typicode.com/posts/%d", id)
return F.Pipe2(
H.MakeGetRequest(url),
H.ReadJSON[Post](client),
RIO.ChainFirstIOK(IO.Logf[Post]("fetched: %v")),
)
}
func handler(w http.ResponseWriter, r *http.Request) {
RIO.Fold(
func(err error) {
http.Error(w, err.Error(), http.StatusBadGateway)
},
func(post Post) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(post)
},
)(fetchPost(1))(r.Context())()
}
```
## Import Reference
```go
import (
HTTP "net/http"
H "github.com/IBM/fp-go/v2/context/readerioresult/http"
RB "github.com/IBM/fp-go/v2/context/readerioresult/http/builder"
B "github.com/IBM/fp-go/v2/http/builder"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
A "github.com/IBM/fp-go/v2/array"
T "github.com/IBM/fp-go/v2/tuple"
IO "github.com/IBM/fp-go/v2/io"
)
```
Requires Go 1.24+.

View File

@@ -0,0 +1,410 @@
# fp-go Logging
## Overview
fp-go provides logging utilities that integrate naturally with functional pipelines. Logging is always a **side effect** — it should not change the value being processed. The library achieves this through `ChainFirst`-style combinators that thread the original value through unchanged while performing the log.
## Packages
| Package | Purpose |
|---------|---------|
| `github.com/IBM/fp-go/v2/logging` | Global logger, context-embedded logger, `LoggingCallbacks` |
| `github.com/IBM/fp-go/v2/io` | `Logf`, `Logger`, `LogGo`, `Printf`, `PrintGo` — IO-level logging helpers |
| `github.com/IBM/fp-go/v2/readerio` | `SLog`, `SLogWithCallback` — structured logging for ReaderIO |
| `github.com/IBM/fp-go/v2/context/readerio` | `SLog`, `SLogWithCallback` — structured logging for context ReaderIO |
| `github.com/IBM/fp-go/v2/context/readerresult` | `SLog`, `TapSLog`, `SLogWithCallback` — structured logging for ReaderResult |
| `github.com/IBM/fp-go/v2/context/readerioresult` | `SLog`, `TapSLog`, `SLogWithCallback`, `LogEntryExit`, `LogEntryExitWithCallback` — full suite for ReaderIOResult |
## Logging Inside Pipelines
The idiomatic way to log inside a monadic pipeline is `ChainFirstIOK` (or `ChainFirst` where the monad is already IO). These combinators execute a side-effecting function and pass the **original value** downstream unchanged.
### With `IOResult` / `ReaderIOResult` — printf-style
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
fetchUser(42),
RIO.ChainEitherK(validateUser),
// Log after validation — value flows through unchanged
RIO.ChainFirstIOK(IO.Logf[User]("Validated user: %v")),
RIO.Map(enrichUser),
)
```
`IO.Logf[A](format string) func(A) IO[A]` logs using `log.Printf` and returns the value unchanged. It's a Kleisli arrow suitable for `ChainFirst` and `ChainFirstIOK`.
### With `IOEither` / plain `IO`
```go
import (
IOE "github.com/IBM/fp-go/v2/ioeither"
IO "github.com/IBM/fp-go/v2/io"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
file.ReadFile("config.json"),
IOE.ChainEitherK(J.Unmarshal[Config]),
IOE.ChainFirstIOK(IO.Logf[Config]("Loaded config: %v")),
IOE.Map[error](processConfig),
)
```
### Logging Arrays in TraverseArray
```go
import (
A "github.com/IBM/fp-go/v2/array"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
F "github.com/IBM/fp-go/v2/function"
)
// Log each item individually, then log the final slice
pipeline := F.Pipe2(
A.MakeBy(3, idxToFilename),
RIO.TraverseArray(F.Flow3(
file.ReadFile,
RIO.ChainEitherK(J.Unmarshal[Record]),
RIO.ChainFirstIOK(IO.Logf[Record]("Parsed record: %v")),
)),
RIO.ChainFirstIOK(IO.Logf[[]Record]("All records: %v")),
)
```
## IO Logging Functions
All live in `github.com/IBM/fp-go/v2/io`:
### `Logf` — printf-style
```go
IO.Logf[A any](format string) func(A) IO[A]
```
Uses `log.Printf`. The format string works like `fmt.Sprintf`.
```go
IO.Logf[User]("Processing user: %+v")
IO.Logf[int]("Count: %d")
```
### `Logger` — with custom `*log.Logger`
```go
IO.Logger[A any](loggers ...*log.Logger) func(prefix string) func(A) IO[A]
```
Uses `logger.Printf(prefix+": %v", value)`. Pass your own `*log.Logger` instance.
```go
customLog := log.New(os.Stderr, "APP ", log.LstdFlags)
logUser := IO.Logger[User](customLog)("user")
// logs: "APP user: {ID:42 Name:Alice}"
```
### `LogGo` — Go template syntax
```go
IO.LogGo[A any](tmpl string) func(A) IO[A]
```
Uses Go's `text/template`. The template receives the value as `.`.
```go
type User struct{ Name string; Age int }
IO.LogGo[User]("User {{.Name}} is {{.Age}} years old")
```
### `Printf` / `PrintGo` — stdout instead of log
Same signatures as `Logf` / `LogGo` but use `fmt.Printf`/`fmt.Println` (no log prefix, no timestamp).
```go
IO.Printf[Result]("Result: %v\n")
IO.PrintGo[User]("Name: {{.Name}}")
```
## Structured Logging in the `context` Package
The `context/readerioresult`, `context/readerresult`, and `context/readerio` packages provide structured `slog`-based logging functions that are context-aware: they retrieve the logger from the context (via `logging.GetLoggerFromContext`) rather than using a fixed logger instance.
### `TapSLog` — inline structured logging in a ReaderIOResult pipeline
`TapSLog` is an **Operator** (`func(ReaderIOResult[A]) ReaderIOResult[A]`). It sits directly in a `F.Pipe` call on a `ReaderIOResult`, logs the current value or error using `slog`, and passes the result through unchanged.
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe4(
fetchOrder(orderID),
RIO.TapSLog[Order]("Order fetched"), // logs value=<Order> or error=<err>
RIO.Chain(validateOrder),
RIO.TapSLog[Order]("Order validated"),
RIO.Chain(processPayment),
)
result, err := pipeline(ctx)()
```
- Logs **both** success values (`value=<A>`) and errors (`error=<err>`) using `slog` structured attributes.
- Respects the logger level — if the logger is configured to discard Info-level logs, nothing is written.
- Available in both `context/readerioresult` and `context/readerresult`.
### `SLog` — Kleisli-style structured logging
`SLog` is a **Kleisli arrow** (`func(Result[A]) ReaderResult[A]` / `func(Result[A]) ReaderIOResult[A]`). It is used with `Chain` when you want to intercept the raw `Result` directly.
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
fetchData(id),
RIO.Chain(RIO.SLog[Data]("Data fetched")), // log raw Result, pass it through
RIO.Chain(validateData),
RIO.Chain(RIO.SLog[Data]("Data validated")),
RIO.Chain(processData),
)
```
**Difference from `TapSLog`:**
- `TapSLog[A](msg)` is an `Operator[A, A]` — used directly in `F.Pipe` on a `ReaderIOResult[A]`.
- `SLog[A](msg)` is a `Kleisli[Result[A], A]` — used with `Chain`, giving access to the raw `Result[A]`.
Both log in the same format. `TapSLog` is more ergonomic in most pipelines.
### `SLogWithCallback` — custom log level and logger source
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
"log/slog"
)
// Log at DEBUG level with a custom logger extracted from context
debugLog := RIO.SLogWithCallback[User](
slog.LevelDebug,
logging.GetLoggerFromContext, // or any func(context.Context) *slog.Logger
"Fetched user",
)
pipeline := F.Pipe2(
fetchUser(123),
RIO.Chain(debugLog),
RIO.Map(func(u User) string { return u.Name }),
)
```
### `LogEntryExit` — automatic entry/exit timing with correlation IDs
`LogEntryExit` wraps a `ReaderIOResult` computation with structured entry and exit log messages. It assigns a unique **correlation ID** (`ID=<n>`) to each invocation so concurrent or nested operations can be correlated in logs.
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
fetchUser(123),
RIO.LogEntryExit[User]("fetchUser"), // wraps the operation
RIO.Chain(func(user User) RIO.ReaderIOResult[[]Order] {
return F.Pipe1(
fetchOrders(user.ID),
RIO.LogEntryExit[[]Order]("fetchOrders"),
)
}),
)
result, err := pipeline(ctx)()
// Logs:
// level=INFO msg="[entering]" name=fetchUser ID=1
// level=INFO msg="[exiting ]" name=fetchUser ID=1 duration=42ms
// level=INFO msg="[entering]" name=fetchOrders ID=2
// level=INFO msg="[exiting ]" name=fetchOrders ID=2 duration=18ms
```
On error, the exit log changes to `[throwing]` and includes the error:
```
level=INFO msg="[throwing]" name=fetchUser ID=3 duration=5ms error="user not found"
```
Key properties:
- **Correlation ID** (`ID=`) is unique per operation, monotonically increasing, and stored in the context so nested operations can access the parent's ID.
- **Duration** (`duration=`) is measured from entry to exit.
- **Logger is taken from the context** — embed a request-scoped logger with `logging.WithLogger` before executing the pipeline and `LogEntryExit` picks it up automatically.
- **Level-aware** — if the logger does not have the log level enabled, the entire entry/exit instrumentation is skipped (zero overhead).
- The original `ReaderIOResult[A]` value flows through **unchanged**.
```go
// Use a context logger so all log messages carry request metadata
cancelFn, ctxWithLogger := pair.Unpack(
logging.WithLogger(
slog.Default().With("requestID", r.Header.Get("X-Request-ID")),
)(r.Context()),
)
defer cancelFn()
result, err := pipeline(ctxWithLogger)()
```
### `LogEntryExitWithCallback` — custom log level
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
"log/slog"
)
// Log at DEBUG level instead of INFO
debugPipeline := F.Pipe1(
expensiveComputation(),
RIO.LogEntryExitWithCallback[Result](
slog.LevelDebug,
logging.GetLoggerFromContext,
"expensiveComputation",
),
)
```
### `SLog` / `SLogWithCallback` in `context/readerresult`
The same `SLog` and `TapSLog` functions are also available in `context/readerresult` for use with the synchronous `ReaderResult[A] = func(context.Context) (A, error)` monad:
```go
import RR "github.com/IBM/fp-go/v2/context/readerresult"
pipeline := F.Pipe3(
queryDB(id),
RR.TapSLog[Row]("Row fetched"),
RR.Chain(parseRow),
RR.TapSLog[Record]("Record parsed"),
)
```
## Global Logger (`logging` package)
The `logging` package manages a global `*slog.Logger` (structured logging, Go 1.21+).
```go
import "github.com/IBM/fp-go/v2/logging"
// Get the current global logger (defaults to slog.Default())
logger := logging.GetLogger()
logger.Info("application started", "version", "1.0")
// Replace the global logger; returns the old one for deferred restore
old := logging.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
defer logging.SetLogger(old)
```
## Context-Embedded Logger
Embed a `*slog.Logger` in a `context.Context` to carry request-scoped loggers across the call stack. All context-package logging functions (`TapSLog`, `SLog`, `LogEntryExit`) pick up this logger automatically.
```go
import (
"github.com/IBM/fp-go/v2/logging"
"github.com/IBM/fp-go/v2/pair"
"log/slog"
)
// Create a request-scoped logger
reqLogger := slog.Default().With("requestID", "abc-123")
// Embed it into a context using the Kleisli arrow WithLogger
cancelFn, ctxWithLogger := pair.Unpack(logging.WithLogger(reqLogger)(ctx))
defer cancelFn()
// All downstream logging (TapSLog, LogEntryExit, etc.) uses reqLogger
result, err := pipeline(ctxWithLogger)()
```
`WithLogger` returns a `ContextCancel = Pair[context.CancelFunc, context.Context]`. The cancel function is a no-op — the context is only enriched, not made cancellable.
`GetLoggerFromContext` falls back to the global logger if no logger is found in the context.
## `LoggingCallbacks` — Dual-Logger Pattern
```go
import "github.com/IBM/fp-go/v2/logging"
// Returns (infoCallback, errorCallback) — both are func(string, ...any)
infoLog, errLog := logging.LoggingCallbacks() // use log.Default() for both
infoLog, errLog := logging.LoggingCallbacks(myLogger) // same logger for both
infoLog, errLog := logging.LoggingCallbacks(infoLog, errorLog) // separate loggers
```
Used internally by `io.Logger` and by packages that need separate info/error sinks.
## Choosing the Right Logging Function
| Situation | Use |
|-----------|-----|
| Quick printf logging mid-pipeline | `IO.Logf[A]("fmt")` with `ChainFirstIOK` |
| Go template formatting mid-pipeline | `IO.LogGo[A]("tmpl")` with `ChainFirstIOK` |
| Print to stdout (no log prefix) | `IO.Printf[A]("fmt")` with `ChainFirstIOK` |
| Structured slog — log value or error inline | `RIO.TapSLog[A]("msg")` (Operator, used in Pipe) |
| Structured slog — intercept raw Result | `RIO.Chain(RIO.SLog[A]("msg"))` (Kleisli) |
| Structured slog — custom log level | `RIO.SLogWithCallback[A](level, cb, "msg")` |
| Entry/exit timing + correlation IDs | `RIO.LogEntryExit[A]("name")` |
| Entry/exit at custom log level | `RIO.LogEntryExitWithCallback[A](level, cb, "name")` |
| Structured logging globally | `logging.GetLogger()` / `logging.SetLogger()` |
| Request-scoped logger in context | `logging.WithLogger(logger)` + `logging.GetLoggerFromContext(ctx)` |
| Custom `*log.Logger` in pipeline | `IO.Logger[A](logger)("prefix")` with `ChainFirstIOK` |
## Complete Example
```go
package main
import (
"context"
"log/slog"
"os"
F "github.com/IBM/fp-go/v2/function"
IO "github.com/IBM/fp-go/v2/io"
L "github.com/IBM/fp-go/v2/logging"
P "github.com/IBM/fp-go/v2/pair"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
)
func main() {
// Configure JSON structured logging globally
L.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
// Embed a request-scoped logger into the context
_, ctx := P.Unpack(L.WithLogger(
L.GetLogger().With("requestID", "req-001"),
)(context.Background()))
pipeline := F.Pipe5(
fetchData(42),
RIO.LogEntryExit[Data]("fetchData"), // entry/exit with timing + ID
RIO.TapSLog[Data]("raw data"), // inline structured value log
RIO.ChainEitherK(transformData),
RIO.LogEntryExit[Result]("transformData"),
RIO.ChainFirstIOK(IO.LogGo[Result]("result: {{.Value}}")), // template log
)
value, err := pipeline(ctx)()
if err != nil {
L.GetLogger().Error("pipeline failed", "error", err)
}
_ = value
}
```

View File

@@ -0,0 +1,520 @@
# fp-go Monadic Operations
## Overview
`fp-go` (import path `github.com/IBM/fp-go/v2`) brings type-safe functional programming to Go using generics. Every monad follows a **consistent interface**: once you know the pattern in one monad, it transfers to all others.
All functions use the **data-last** principle: the data being transformed is always the last argument, enabling partial application and pipeline composition.
## Core Types
| Type | Package | Represents |
|------|---------|------------|
| `Option[A]` | `option` | A value that may or may not be present (replaces nil) |
| `Either[E, A]` | `either` | A value that is either a left error `E` or a right success `A` |
| `Result[A]` | `result` | `Either[error, A]` — shorthand for the common case |
| `IO[A]` | `io` | A lazy computation that produces `A` (possibly with side effects) |
| `IOResult[A]` | `ioresult` | `IO[Result[A]]` — lazy computation that can fail |
| `ReaderIOResult[A]` | `context/readerioresult` | `func(context.Context) IOResult[A]` — context-aware IO with errors |
| `Effect[C, A]` | `effect` | `func(C) ReaderIOResult[A]` — typed dependency injection + IO + errors |
Idiomatic (high-performance, tuple-based) equivalents live in `idiomatic/`:
- `idiomatic/option``(A, bool)` tuples
- `idiomatic/result``(A, error)` tuples
- `idiomatic/ioresult``func() (A, error)`
- `idiomatic/context/readerresult``func(context.Context) (A, error)`
## Standard Operations
Every monad exports these operations (PascalCase for exported Go names):
| fp-go | fp-ts / Haskell | Description |
|-------|----------------|-------------|
| `Of` | `of` / `pure` | Lift a pure value into the monad |
| `Map` | `map` / `fmap` | Transform the value inside without changing the context |
| `Chain` | `chain` / `>>=` | Sequence a computation that itself returns a monadic value |
| `Ap` | `ap` / `<*>` | Apply a wrapped function to a wrapped value |
| `Fold` | `fold` / `either` | Eliminate the context — handle every case and extract a plain value |
| `GetOrElse` | `getOrElse` / `fromMaybe` | Extract the value or use a default (Option/Result) |
| `Filter` | `filter` / `mfilter` | Keep only values satisfying a predicate |
| `Flatten` | `flatten` / `join` | Remove one level of nesting (`M[M[A]]``M[A]`) |
| `ChainFirst` | `chainFirst` / `>>` | Sequence for side effects; keeps the original value |
| `Alt` | `alt` / `<\|>` | Provide an alternative when the first computation fails |
| `FromPredicate` | `fromPredicate` / `guard` | Build a monadic value from a predicate |
| `Sequence` | `sequence` | Turn `[]M[A]` into `M[[]A]` |
| `Traverse` | `traverse` | Map and sequence in one step |
Curried (composable) vs. monadic (direct) form:
```go
// Curried — data last, returns a transformer function
option.Map(strings.ToUpper) // func(Option[string]) Option[string]
// Monadic — data first, immediate execution
option.MonadMap(option.Some("hello"), strings.ToUpper)
```
Use curried form for pipelines; use `Monad*` form when you already have all arguments.
## Key Type Aliases (defined per monad)
```go
// A Kleisli arrow: a function from A to a monadic B
type Kleisli[A, B any] = func(A) M[B]
// An operator: transforms one monadic value into another
type Operator[A, B any] = func(M[A]) M[B]
```
`Chain` takes a `Kleisli`, `Map` returns an `Operator`. The naming is consistent across all monads.
## Examples
### Option — nullable values without nil
```go
import (
O "github.com/IBM/fp-go/v2/option"
F "github.com/IBM/fp-go/v2/function"
"strconv"
)
parseAndDouble := F.Flow2(
O.FromPredicate(func(s string) bool { return s != "" }),
O.Chain(func(s string) O.Option[int] {
n, err := strconv.Atoi(s)
if err != nil {
return O.None[int]()
}
return O.Some(n * 2)
}),
)
parseAndDouble("21") // Some(42)
parseAndDouble("") // None
parseAndDouble("abc") // None
```
### Result — error handling without if-err boilerplate
```go
import (
R "github.com/IBM/fp-go/v2/result"
F "github.com/IBM/fp-go/v2/function"
"strconv"
"errors"
)
parse := R.Eitherize1(strconv.Atoi) // lifts (int, error) → Result[int]
validate := func(n int) R.Result[int] {
if n < 0 {
return R.Error[int](errors.New("must be non-negative"))
}
return R.Of(n)
}
pipeline := F.Flow2(parse, R.Chain(validate))
pipeline("42") // Ok(42)
pipeline("-1") // Error("must be non-negative")
pipeline("abc") // Error(strconv parse error)
```
### IOResult — lazy IO with error handling
```go
import (
IOE "github.com/IBM/fp-go/v2/ioresult"
F "github.com/IBM/fp-go/v2/function"
J "github.com/IBM/fp-go/v2/json"
"os"
)
readConfig := F.Flow2(
IOE.Eitherize1(os.ReadFile), // func(string) IOResult[[]byte]
IOE.ChainEitherK(J.Unmarshal[Config]), // parse JSON, propagate errors
)
result := readConfig("config.json")() // execute lazily
```
### ReaderIOResult — context-aware pipelines (recommended for services)
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
"context"
)
// type ReaderIOResult[A any] = func(context.Context) func() result.Result[A]
fetchUser := func(id int) RIO.ReaderIOResult[User] {
return func(ctx context.Context) func() result.Result[User] {
return func() result.Result[User] {
// perform IO here
}
}
}
pipeline := F.Pipe3(
fetchUser(42),
RIO.ChainEitherK(validateUser), // lift pure (User, error) function
RIO.Map(enrichUser), // lift pure User → User function
RIO.ChainFirstIOK(IO.Logf[User]("Fetched: %v")), // side-effect logging
)
user, err := pipeline(ctx)() // provide context once, execute
```
### Traversal — process slices monadically
```go
import (
A "github.com/IBM/fp-go/v2/array"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
)
// Fetch all users, stop on first error
fetchAll := F.Pipe1(
A.MakeBy(10, userID),
RIO.TraverseArray(fetchUser), // []ReaderIOResult[User] → ReaderIOResult[[]User]
)
```
## Function Composition with Flow and Pipe
```go
import F "github.com/IBM/fp-go/v2/function"
// Flow: compose functions left-to-right, returns a new function
transform := F.Flow3(
option.Map(strings.TrimSpace),
option.Filter(func(s string) bool { return s != "" }),
option.GetOrElse(func() string { return "default" }),
)
result := transform(option.Some(" hello ")) // "hello"
// Pipe: apply a value through a pipeline immediately
result := F.Pipe3(
option.Some(" hello "),
option.Map(strings.TrimSpace),
option.Filter(func(s string) bool { return s != "" }),
option.GetOrElse(func() string { return "default" }),
)
```
## Lifting Pure Functions into Monadic Context
fp-go provides helpers to promote non-monadic functions:
| Helper | Lifts |
|--------|-------|
| `ChainEitherK` | `func(A) (B, error)` → works inside the monad |
| `ChainOptionK` | `func(A) Option[B]` → works inside the monad |
| `ChainFirstIOK` | `func(A) IO[B]` for side effects, keeps original value |
| `Eitherize1..N` | `func(A) (B, error)``func(A) Result[B]` |
| `FromPredicate` | `func(A) bool` + error builder → `func(A) Result[A]` |
## Type Parameter Ordering Rule (V2)
Non-inferrable type parameters come **first**, so the compiler can infer the rest:
```go
// B cannot be inferred from the argument — it comes first
result := either.Ap[string](value)(funcInEither)
// All types inferrable — no explicit params needed
result := either.Map(transform)(value)
result := either.Chain(validator)(value)
```
## When to Use Which Monad
| Situation | Use |
|-----------|-----|
| Value that might be absent | `Option[A]` |
| Operation that can fail with custom error type | `Either[E, A]` |
| Operation that can fail with `error` | `Result[A]` |
| Lazy IO, side effects | `IO[A]` |
| IO that can fail | `IOResult[A]` |
| IO + context (cancellation, deadlines) | `ReaderIOResult[A]` from `context/readerioresult` |
| IO + context + typed dependencies | `Effect[C, A]` |
| High-performance services | Idiomatic packages in `idiomatic/` |
## Do-Notation: Accumulating State with `Bind` and `ApS`
When a pipeline needs to carry **multiple intermediate results** forward — not just a single value — the `Chain`/`Map` style becomes unwieldy because each step only threads one value and prior results are lost. Do-notation solves this by accumulating results into a growing struct (the "state") at each step.
Every monad that supports do-notation exports the same family of functions. The examples below use `context/readerioresult` (`RIO`), but the identical API is available in `result`, `option`, `ioresult`, `readerioresult`, and others.
### The Function Family
| Function | Kind | What it does |
|----------|------|-------------|
| `Do(empty S)` | — | Lift an empty struct into the monad; starting point |
| `BindTo(setter)` | monadic | Convert an existing `M[T]` into `M[S]`; alternative start |
| `Bind(setter, f)` | monadic | Add a result; `f` receives the **current state** and returns `M[T]` |
| `ApS(setter, fa)` | applicative | Add a result; `fa` is **independent** of the current state |
| `Let(setter, f)` | pure | Add a value computed by a **pure function** of the state |
| `LetTo(setter, value)` | pure | Add a **constant** value |
Lens variants (`BindL`, `ApSL`, `LetL`, `LetToL`) accept a `Lens[S, T]` instead of a manual setter, integrating naturally with the optics system.
### `Bind` — Sequential, Dependent Steps
`Bind` sequences two monadic computations. The function `f` receives the **full accumulated state** so it can read anything gathered so far. Errors short-circuit automatically.
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
L "github.com/IBM/fp-go/v2/optics/lens"
"context"
)
type Pipeline struct {
User User
Config Config
Posts []Post
}
// Lenses — focus on individual fields; .Set is already func(T) func(S) S
var (
userLens = L.MakeLens(func(s Pipeline) User { return s.User }, func(s Pipeline, u User) Pipeline { s.User = u; return s })
configLens = L.MakeLens(func(s Pipeline) Config { return s.Config }, func(s Pipeline, c Config) Pipeline { s.Config = c; return s })
postsLens = L.MakeLens(func(s Pipeline) []Post { return s.Posts }, func(s Pipeline, p []Post) Pipeline { s.Posts = p; return s })
)
result := F.Pipe3(
RIO.Do(Pipeline{}), // lift empty struct
RIO.Bind(userLens.Set, func(_ Pipeline) RIO.ReaderIOResult[User] { return fetchUser(42) }),
RIO.Bind(configLens.Set, F.Flow2(userLens.Get, fetchConfigForUser)), // read s.User, pass to fetcher
RIO.Bind(postsLens.Set, F.Flow2(userLens.Get, fetchPostsForUser)), // read s.User, pass to fetcher
)
pipeline, err := result(context.Background())()
// pipeline.User, pipeline.Config, pipeline.Posts are all populated
```
The setter signature is `func(T) func(S1) S2` — it takes the new value and returns a state transformer. `lens.Set` already has this shape, so no manual setter functions are needed. `F.Flow2(lens.Get, f)` composes the field getter with any Kleisli arrow `f` point-free.
### `ApS` — Independent, Applicative Steps
`ApS` uses **applicative** semantics: `fa` is evaluated without any access to the current state. Use it when steps have no dependency on each other — the library can choose to execute them concurrently.
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
L "github.com/IBM/fp-go/v2/optics/lens"
)
type Summary struct {
User User
Weather Weather
}
var (
userLens = L.MakeLens(func(s Summary) User { return s.User }, func(s Summary, u User) Summary { s.User = u; return s })
weatherLens = L.MakeLens(func(s Summary) Weather { return s.Weather }, func(s Summary, w Weather) Summary { s.Weather = w; return s })
)
// Both are independent — neither needs the other's result
result := F.Pipe2(
RIO.Do(Summary{}),
RIO.ApS(userLens.Set, fetchUser(42)),
RIO.ApS(weatherLens.Set, fetchWeather("NYC")),
)
```
**Key difference from `Bind`:**
| | `Bind(setter, f)` | `ApS(setter, fa)` |
|-|---|---|
| Second argument | `func(S1) M[T]` — a **function** of state | `M[T]` — a **fixed** monadic value |
| Can read prior state? | Yes — receives `S1` | No — no access to state |
| Semantics | Monadic (sequential) | Applicative (independent) |
### `Let` and `LetTo` — Pure Additions
`Let` adds a value computed by a **pure function** of the current state (no monad, cannot fail):
```go
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
L "github.com/IBM/fp-go/v2/optics/lens"
)
type Enriched struct {
User User
FullName string
}
var (
userLens = L.MakeLens(func(s Enriched) User { return s.User }, func(s Enriched, u User) Enriched { s.User = u; return s })
fullNameLens = L.MakeLens(func(s Enriched) string { return s.FullName }, func(s Enriched, n string) Enriched { s.FullName = n; return s })
)
fullName := func(u User) string { return u.FirstName + " " + u.LastName }
result := F.Pipe2(
RIO.Do(Enriched{}),
RIO.Bind(userLens.Set, func(_ Enriched) RIO.ReaderIOResult[User] { return fetchUser(42) }),
RIO.Let(fullNameLens.Set, F.Flow2(userLens.Get, fullName)), // read s.User, compute pure string
)
```
`LetTo` adds a **constant** with no computation:
```go
RIO.LetTo(setVersion, "v1.2.3")
```
### `BindTo` — Starting from an Existing Value
When you have an existing `M[T]` and want to project it into a state struct rather than starting from `Do(empty)`:
```go
type State struct{ User User }
result := F.Pipe1(
fetchUser(42), // ReaderIOResult[User]
RIO.BindTo(func(u User) State { return State{User: u} }),// ReaderIOResult[State]
)
```
### Lens Variants (`ApSL`, `BindL`, `LetL`, `LetToL`)
If you have a `Lens[S, T]` (from the optics system or code generation), you can skip writing the setter function entirely:
```go
import (
RO "github.com/IBM/fp-go/v2/readeroption"
F "github.com/IBM/fp-go/v2/function"
)
// Lenses generated by go:generate (see optics/README.md)
// personLenses.Name : Lens[*Person, Name]
// personLenses.Age : Lens[*Person, Age]
makePerson := F.Pipe2(
RO.Do[*PartialPerson](emptyPerson),
RO.ApSL(personLenses.Name, maybeName), // replaces: ApS(personLenses.Name.Set, maybeName)
RO.ApSL(personLenses.Age, maybeAge),
)
```
This exact pattern is used in [`samples/builder`](samples/builder/builder.go) to validate and construct a `Person` from an unvalidated `PartialPerson`.
### Lifted Variants for Mixed Monads
`context/readerioresult` provides `Bind*K` helpers that lift simpler computations directly into the do-chain:
| Helper | Lifts |
|--------|-------|
| `BindResultK` / `BindEitherK` | `func(S1) (T, error)` — pure result |
| `BindIOResultK` / `BindIOEitherK` | `func(S1) func() (T, error)` — lazy IO result |
| `BindIOK` | `func(S1) func() T` — infallible IO |
| `BindReaderK` | `func(S1) func(ctx) T` — context reader |
```go
RIO.BindResultK(setUser, func(s Pipeline) (User, error) {
return validateAndBuild(s) // plain (value, error) function, no wrapping needed
})
```
### Decision Guide
```
Does the new step need to read prior accumulated state?
YES → Bind (monadic, sequential; f receives current S)
NO → ApS (applicative, independent; fa is a fixed M[T])
Is the new value derived purely from state, with no monad?
YES → Let (pure function of S)
Is the new value a compile-time or runtime constant?
YES → LetTo
Starting from an existing M[T] rather than an empty struct?
YES → BindTo
```
### Complete Example — `result` Monad
The same pattern works with simpler monads. Here with `result.Result[A]`:
`Eitherize1` converts any standard `func(A) (B, error)` into `func(A) Result[B]`. Define these lifted functions once as variables. Then use lenses to focus on individual struct fields and compose with `F.Flow2(lens.Get, f)` — no inline lambdas, no manual error handling.
```go
import (
R "github.com/IBM/fp-go/v2/result"
F "github.com/IBM/fp-go/v2/function"
L "github.com/IBM/fp-go/v2/optics/lens"
N "github.com/IBM/fp-go/v2/number"
"strconv"
)
type Parsed struct {
Raw string
Number int
Double int
}
// Lenses — focus on individual fields of Parsed.
var (
rawLens = L.MakeLens(
func(s Parsed) string { return s.Raw },
func(s Parsed, v string) Parsed { s.Raw = v; return s },
)
numberLens = L.MakeLens(
func(s Parsed) int { return s.Number },
func(s Parsed, v int) Parsed { s.Number = v; return s },
)
doubleLens = L.MakeLens(
func(s Parsed) int { return s.Double },
func(s Parsed, v int) Parsed { s.Double = v; return s },
)
)
// Lifted functions — convert standard (value, error) functions into Result-returning ones.
var (
atoi = R.Eitherize1(strconv.Atoi) // func(string) Result[int]
)
parse := func(input string) R.Result[Parsed] {
return F.Pipe3(
R.Do(Parsed{}),
R.LetTo(rawLens.Set, input), // set Raw to constant input
R.Bind(numberLens.Set, F.Flow2(rawLens.Get, atoi)), // get Raw, parse → Result[int]
R.Let(doubleLens.Set, F.Flow2(numberLens.Get, N.Mul(2))), // get Number, multiply → int
)
}
parse("21") // Ok(Parsed{Raw:"21", Number:21, Double:42})
parse("abc") // Error(strconv parse error)
```
`rawLens.Set` is already `func(string) func(Parsed) Parsed`, matching the setter signature `Bind` and `LetTo` expect — no manual setter functions to write. `F.Flow2(rawLens.Get, atoi)` composes the field getter with the eitherized parse function into a `Kleisli[Parsed, int]` without any intermediate lambda.
## Import Paths
```go
import (
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/effect"
F "github.com/IBM/fp-go/v2/function"
A "github.com/IBM/fp-go/v2/array"
)
```
Requires Go 1.24+ (generic type aliases).

238
v2/AGENTS.md Normal file
View File

@@ -0,0 +1,238 @@
# Agent Guidelines for fp-go/v2
This document provides guidelines for AI agents working on the fp-go/v2 project.
## Table of Contents
- [Documentation Standards](#documentation-standards)
- [Go Doc Comments](#go-doc-comments)
- [File Headers](#file-headers)
- [Testing Standards](#testing-standards)
- [Test Structure](#test-structure)
- [Test Coverage](#test-coverage)
- [Example Test Pattern](#example-test-pattern)
- [Code Style](#code-style)
- [Functional Patterns](#functional-patterns)
- [Error Handling](#error-handling)
- [Checklist for New Code](#checklist-for-new-code)
## Documentation Standards
### Go Doc Comments
1. **Use Standard Go Doc Format**
- Do NOT use markdown-style links like `[text](url)`
- Do NOT use markdown-style headers like `# Section` or `## Subsection`
- Use simple type references: `ReaderResult`, `Validate[I, A]`, `validation.Success`
- Go's documentation system will automatically create links
- Use plain text with blank lines to separate sections
2. **Structure**
```go
// FunctionName does something useful.
//
// Longer description explaining the purpose and behavior.
//
// Type Parameters:
// - T: Description of type parameter
//
// Parameters:
// - param: Description of parameter
//
// Returns:
// - ReturnType: Description of return value
//
// Example:
//
// code example here
//
// See Also:
// - RelatedFunction: Brief description
func FunctionName[T any](param T) ReturnType {
```
3. **Code Examples**
- Use idiomatic Go patterns
- Prefer `result.Eitherize1(strconv.Atoi)` over manual error handling
- Show realistic, runnable examples
- Indent code examples with spaces (not tabs) for proper godoc rendering
### File Headers
Always include the Apache 2.0 license header:
```go
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
```
## Testing Standards
### Test Structure
1. **Organize Tests by Category**
```go
func TestFunctionName_Success(t *testing.T) {
t.Run("specific success case", func(t *testing.T) {
// test code
})
}
func TestFunctionName_Failure(t *testing.T) {
t.Run("specific failure case", func(t *testing.T) {
// test code
})
}
func TestFunctionName_EdgeCases(t *testing.T) {
// edge case tests
}
func TestFunctionName_Integration(t *testing.T) {
// integration tests
}
```
2. **Use Direct Assertions**
- Prefer: `assert.Equal(t, validation.Success(expected), actual)`
- Avoid: Verbose `either.MonadFold` patterns unless necessary
- Exception: When you need to verify pointer is not nil or extract specific fields
3. **Use Idiomatic Patterns**
- Use `result.Eitherize1` for converting `(T, error)` functions
- Use `result.Of` for success values
- Use `result.Left` for error values
4. **Folding Either/Result Values in Tests**
- Use `F.Pipe1(result, Fold(onLeft, onRight))` — avoid the `_ = Fold(...)(result)` discard pattern
- Use `slices.Collect[T]` instead of a manual `for n := range seq { collected = append(...) }` loop
- Use `t.Fatal` in the unexpected branch to combine the `IsLeft`/`IsRight` check with value extraction:
```go
// Good: single fold combines assertion and extraction
collected := F.Pipe1(result, Fold(
func(e error) []int { t.Fatal(e); return nil },
slices.Collect[int],
))
// Avoid: separate IsRight check + manual loop
assert.True(t, IsRight(result))
var collected []int
_ = MonadFold(result,
func(e error) []int { return nil },
func(seq iter.Seq[int]) []int {
for n := range seq { collected = append(collected, n) }
return collected
},
)
```
- Use `F.Identity[error]` as the Left branch when extracting an error value:
```go
err := F.Pipe1(result, Fold(
F.Identity[error],
func(_ iter.Seq[int]) error { t.Fatal("expected Left but got Right"); return nil },
))
```
- Extract repeated fold patterns as local helper closures within the test function:
```go
collectInts := func(r Result[iter.Seq[int]]) []int {
return F.Pipe1(r, Fold(
func(e error) []int { t.Fatal(e); return nil },
slices.Collect[int],
))
}
```
5. **Other Test Style Details**
- Use `for i := range 10` instead of `for i := 0; i < 10; i++`
- Chain curried calls directly: `TraverseSeq(parse)(input)` — no need for an intermediate `traverseFn` variable
- Use direct slice literals (`[]string{"a", "b"}`) rather than `A.From("a", "b")` in tests
### Test Coverage
Include tests for:
- **Success cases**: Normal operation with various input types
- **Failure cases**: Error handling and error preservation
- **Edge cases**: Nil, empty, zero values, boundary conditions
- **Integration**: Composition with other functions
- **Type safety**: Verify type parameters work correctly
- **Benchmarks**: Performance-critical paths
### Example Test Pattern
```go
func TestFromReaderResult_Success(t *testing.T) {
t.Run("converts successful ReaderResult", func(t *testing.T) {
// Arrange
parseIntRR := result.Eitherize1(strconv.Atoi)
validator := FromReaderResult[string, int](parseIntRR)
// Act
result := validator("42")(nil)
// Assert
assert.Equal(t, validation.Success(42), result)
})
}
```
## Code Style
### Functional Patterns
1. **Prefer Composition**
```go
validator := F.Pipe1(
FromReaderResult[string, int](parseIntRR),
Chain(validatePositive),
)
```
2. **Use Type-Safe Helpers**
- `result.Eitherize1` for `func(T) (R, error)`
- `result.Of` for wrapping success values
- `result.Left` for wrapping errors
3. **Avoid Verbose Patterns**
- Don't manually handle `(value, error)` tuples when helpers exist
- Don't use `either.MonadFold` in tests unless necessary
4. **Use Void Type for Unit Values**
- Use `function.Void` (or `F.Void`) instead of `struct{}`
- Use `function.VOID` (or `F.VOID`) instead of `struct{}{}`
- Example: `Empty[F.Void, F.Void, any](lazy.Of(pair.MakePair(F.VOID, F.VOID)))`
### Error Handling
1. **In Production Code**
- Use `validation.Success` for successful validations
- Use `validation.FailureWithMessage` for simple failures
- Use `validation.FailureWithError` to preserve error causes
2. **In Tests**
- Verify error messages and causes
- Check error context is preserved
- Test error accumulation when applicable
## Checklist for New Code
- [ ] Apache 2.0 license header included
- [ ] Go doc comments use standard format (no markdown links)
- [ ] Code examples are idiomatic and concise
- [ ] Tests cover success, failure, edge cases, and integration
- [ ] Tests use direct assertions where possible
- [ ] Benchmarks included for performance-critical code
- [ ] All tests pass
- [ ] Code uses functional composition patterns
- [ ] Error handling preserves context and causes

View File

@@ -1,6 +1,6 @@
# Functional I/O in Go: Context, Errors, and the Reader Pattern
This document explores how functional programming principles apply to I/O operations in Go, comparing traditional imperative approaches with functional patterns using the `context/readerioresult` and `idiomatic/context/readerresult` packages.
This document explores how functional programming principles apply to I/O operations in Go, comparing traditional imperative approaches with functional patterns using the `context/readerioresult`, `idiomatic/context/readerresult`, and `effect` packages.
## Table of Contents
@@ -10,6 +10,7 @@ This document explores how functional programming principles apply to I/O operat
- [Benefits of the Functional Approach](#benefits-of-the-functional-approach)
- [Side-by-Side Comparison](#side-by-side-comparison)
- [Advanced Patterns](#advanced-patterns)
- [The Effect Package: Higher-Level Abstraction](#the-effect-package-higher-level-abstraction)
- [When to Use Each Approach](#when-to-use-each-approach)
## Why Context in I/O Operations
@@ -775,6 +776,191 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
}
```
## The Effect Package: Higher-Level Abstraction
### What is Effect?
The `effect` package provides a higher-level abstraction over `ReaderReaderIOResult`, offering a complete effect system for managing dependencies, errors, and side effects in a composable way. It's inspired by [effect-ts](https://effect.website/) and provides a cleaner API for complex workflows.
### Core Type
```go
// Effect represents an effectful computation that:
// - Requires a context of type C (dependency injection)
// - Can perform I/O operations
// - Can fail with an error
// - Produces a value of type A on success
type Effect[C, A any] = readerreaderioresult.ReaderReaderIOResult[C, A]
```
**Key difference from ReaderIOResult**: Effect adds an additional layer of dependency injection (the `C` type parameter) on top of the runtime `context.Context`, enabling type-safe dependency management.
### When to Use Effect
Use the Effect package when you need:
1. **Type-Safe Dependency Injection**: Your application has typed dependencies (config, services, repositories) that need to be threaded through operations
2. **Complex Workflows**: Multiple services and dependencies need to be composed
3. **Testability**: You want to easily mock dependencies by providing different contexts
4. **Separation of Concerns**: Clear separation between business logic, dependencies, and I/O
### Effect vs ReaderIOResult
```go
// ReaderIOResult - depends only on runtime context
type ReaderIOResult[A any] = func(context.Context) (A, error)
// Effect - depends on typed context C AND runtime context
type Effect[C, A any] = func(C) func(context.Context) (A, error)
```
**ReaderIOResult** is simpler and suitable when you only need runtime context (cancellation, deadlines, request-scoped values).
**Effect** adds typed dependency injection, making it ideal for applications with complex service dependencies.
### Basic Usage
#### Creating Effects
```go
type AppConfig struct {
DatabaseURL string
APIKey string
}
// Create a successful effect
successEffect := effect.Succeed[AppConfig, string]("hello")
// Create a failed effect
failEffect := effect.Fail[AppConfig, string](errors.New("failed"))
// Lift a pure value
pureEffect := effect.Of[AppConfig, int](42)
```
#### Integrating Standard Go Functions
The `Eitherize` function makes it easy to integrate standard Go functions that return `(value, error)`:
```go
type Database struct {
conn *sql.DB
}
// Convert a standard Go function to an Effect using Eitherize
func fetchUser(id int) effect.Effect[Database, User] {
return effect.Eitherize(func(db Database, ctx context.Context) (User, error) {
var user User
err := db.conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user)
return user, err
})
}
// Use Eitherize1 for Kleisli arrows (functions with an additional parameter)
fetchUserKleisli := effect.Eitherize1(func(db Database, ctx context.Context, id int) (User, error) {
var user User
err := db.conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user)
return user, err
})
// fetchUserKleisli has type: func(int) Effect[Database, User]
```
#### Composing Effects
```go
type Services struct {
UserRepo UserRepository
EmailSvc EmailService
}
// Compose multiple effects with typed dependencies
func processUser(id int, newEmail string) effect.Effect[Services, User] {
return F.Pipe3(
// Fetch user from repository
effect.Eitherize(func(svc Services, ctx context.Context) (User, error) {
return svc.UserRepo.GetUser(ctx, id)
}),
// Validate user (pure function lifted into Effect)
effect.ChainEitherK[Services](validateUser),
// Update email
effect.Chain[Services](func(user User) effect.Effect[Services, User] {
return effect.Eitherize(func(svc Services, ctx context.Context) (User, error) {
if err := svc.EmailSvc.SendVerification(ctx, newEmail); err != nil {
return User{}, err
}
return svc.UserRepo.UpdateEmail(ctx, user.ID, newEmail)
})
}),
)
}
```
#### Running Effects
```go
func main() {
// Set up typed dependencies once
services := Services{
UserRepo: &PostgresUserRepo{db: db},
EmailSvc: &SMTPEmailService{host: "smtp.example.com"},
}
// Build the effect pipeline (no execution yet)
userEffect := processUser(42, "new@email.com")
// Provide dependencies - returns a Thunk (ReaderIOResult)
thunk := effect.Provide(services)(userEffect)
// Run synchronously - returns a func(context.Context) (User, error)
readerResult := effect.RunSync(thunk)
// Execute with runtime context
user, err := readerResult(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Updated user: %+v\n", user)
}
```
### Comparison: Traditional vs ReaderIOResult vs Effect
| Aspect | Traditional | ReaderIOResult | Effect |
|---|---|---|---|
| Error propagation | Manual | Automatic | Automatic |
| Dependency injection | Function parameters | Closure / `context.Context` | Typed `C` parameter |
| Testability | Requires mocking | Mock `ReaderIOResult` | Provide mock `C` |
| Composability | Low | High | High |
| Type-safe dependencies | No | No | Yes |
| Complexity | Low | Medium | Medium-High |
### Testing with Effect
One of the key benefits of Effect is easy testing through dependency substitution:
```go
func TestProcessUser(t *testing.T) {
// Create mock services
mockServices := Services{
UserRepo: &MockUserRepo{
users: map[int]User{42: {ID: 42, Age: 25, Email: "old@email.com"}},
},
EmailSvc: &MockEmailService{},
}
// Run the effect with mock dependencies
user, err := effect.RunSync(
effect.Provide(mockServices)(processUser(42, "new@email.com")),
)(context.Background())
assert.NoError(t, err)
assert.Equal(t, "new@email.com", user.Email)
}
```
No database or SMTP server needed — just provide mock implementations of the `Services` struct.
## When to Use Each Approach
### Use Traditional Go Style When:
@@ -794,6 +980,17 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
5. **Resource management**: Need guaranteed cleanup (Bracket)
6. **Parallel execution**: Need to parallelize operations easily
7. **Type safety**: Want the type system to track I/O effects
8. **Simple dependencies**: Only need runtime `context.Context`, no typed dependencies
### Use Effect When:
1. **Type-safe dependency injection**: Application has typed dependencies (config, services, repositories)
2. **Complex service architectures**: Multiple services need to be composed with clear dependency management
3. **Testability with mocks**: Want to easily substitute dependencies for testing
4. **Separation of concerns**: Need clear separation between business logic, dependencies, and I/O
5. **Large applications**: Building applications where dependency management is critical
6. **Team experience**: Team is comfortable with functional programming and effect systems
7. **Integration with standard Go**: Need to integrate many standard `(value, error)` functions using `Eitherize`
### Use Idiomatic Functional Style (idiomatic/context/readerresult) When:
@@ -803,6 +1000,7 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
4. **Go integration**: Want seamless integration with existing Go code
5. **Production services**: Building high-throughput services
6. **Best of both worlds**: Want functional composition with Go's native patterns
7. **Simple dependencies**: Only need runtime `context.Context`, no typed dependencies
## Summary
@@ -814,11 +1012,18 @@ The functional approach to I/O in Go offers significant advantages:
4. **Type Safety**: I/O effects visible in the type system
5. **Lazy Evaluation**: Build pipelines, execute when ready
6. **Context Propagation**: Automatic threading of context
7. **Performance**: Idiomatic version offers 2-10x speedup
7. **Dependency Injection**: Type-safe dependency management with Effect
8. **Performance**: Idiomatic version offers 2-10x speedup
The key insight is that **I/O operations return descriptions of effects** (ReaderIOResult) rather than performing effects immediately. This enables powerful composition patterns while maintaining Go's idiomatic error handling through the `(value, error)` tuple pattern.
The key insight is that **I/O operations return descriptions of effects** rather than performing effects immediately. This enables powerful composition patterns while maintaining Go's idiomatic error handling through the `(value, error)` tuple pattern.
For production Go services, the **idiomatic/context/readerresult** package provides the best balance: full functional programming capabilities with native Go performance and familiar error handling patterns.
### Choosing the Right Abstraction
- **ReaderIOResult**: Best for simple I/O pipelines that only need runtime `context.Context`
- **Effect**: Best for complex applications with typed dependencies and service architectures
- **idiomatic/context/readerresult**: Best for production services needing high performance with functional patterns
For production Go services, the **idiomatic/context/readerresult** package provides the best balance of performance and functional capabilities. For applications with complex dependency management, the **effect** package provides type-safe dependency injection with a clean, composable API.
## Further Reading
@@ -826,4 +1031,6 @@ For production Go services, the **idiomatic/context/readerresult** package provi
- [IDIOMATIC_COMPARISON.md](./IDIOMATIC_COMPARISON.md) - Performance comparison
- [idiomatic/doc.go](./idiomatic/doc.go) - Idiomatic package overview
- [context/readerioresult](./context/readerioresult/) - ReaderIOResult package
- [idiomatic/context/readerresult](./idiomatic/context/readerresult/) - Idiomatic ReaderResult package
- [idiomatic/context/readerresult](./idiomatic/context/readerresult/) - Idiomatic ReaderResult package
- [effect](./effect/) - Effect package for type-safe dependency injection
- [effect-ts](https://effect.website/) - TypeScript effect system that inspired this package

View File

@@ -3,6 +3,7 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/IBM/fp-go/v2.svg)](https://pkg.go.dev/github.com/IBM/fp-go/v2)
[![Coverage Status](https://coveralls.io/repos/github/IBM/fp-go/badge.svg?branch=main&flag=v2)](https://coveralls.io/github/IBM/fp-go?branch=main)
[![Go Report Card](https://goreportcard.com/badge/github.com/IBM/fp-go/v2)](https://goreportcard.com/report/github.com/IBM/fp-go/v2)
[![Context7](https://img.shields.io/badge/context7-docs-blue)](https://context7.com/ibm/fp-go)
**fp-go** is a comprehensive functional programming library for Go, bringing type-safe functional patterns inspired by [fp-ts](https://gcanti.github.io/fp-ts/) to the Go ecosystem. Version 2 leverages [generic type aliases](https://github.com/golang/go/issues/46477) introduced in Go 1.24, providing a more ergonomic and streamlined API.
@@ -461,7 +462,8 @@ func process() IOResult[string] {
- **Result** - Simplified Either with error as left type (recommended for error handling)
- **IO** - Lazy evaluation and side effect management
- **IOOption** - Combine IO with Option for optional values with side effects
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither)
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither when using standard `error` type)
- **Effect** - Composable effects with dependency injection and error handling
- **Reader** - Dependency injection pattern
- **ReaderOption** - Combine Reader with Option for optional values with dependency injection
- **ReaderIOOption** - Combine Reader, IO, and Option for optional values with dependency injection and side effects

File diff suppressed because it is too large Load Diff

522
v2/array/array_nil_test.go Normal file
View File

@@ -0,0 +1,522 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package array
import (
"fmt"
"testing"
O "github.com/IBM/fp-go/v2/option"
P "github.com/IBM/fp-go/v2/pair"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
)
// TestNilSlice_IsEmpty verifies that IsEmpty handles nil slices correctly
func TestNilSlice_IsEmpty(t *testing.T) {
var nilSlice []int
assert.True(t, IsEmpty(nilSlice), "nil slice should be empty")
}
// TestNilSlice_IsNonEmpty verifies that IsNonEmpty handles nil slices correctly
func TestNilSlice_IsNonEmpty(t *testing.T) {
var nilSlice []int
assert.False(t, IsNonEmpty(nilSlice), "nil slice should not be non-empty")
}
// TestNilSlice_MonadMap verifies that MonadMap handles nil slices correctly
func TestNilSlice_MonadMap(t *testing.T) {
var nilSlice []int
result := MonadMap(nilSlice, func(v int) string {
return fmt.Sprintf("%d", v)
})
assert.NotNil(t, result, "MonadMap should return non-nil slice")
assert.Equal(t, 0, len(result), "MonadMap should return empty slice for nil input")
}
// TestNilSlice_MonadMapRef verifies that MonadMapRef handles nil slices correctly
func TestNilSlice_MonadMapRef(t *testing.T) {
var nilSlice []int
result := MonadMapRef(nilSlice, func(v *int) string {
return fmt.Sprintf("%d", *v)
})
assert.NotNil(t, result, "MonadMapRef should return non-nil slice")
assert.Equal(t, 0, len(result), "MonadMapRef should return empty slice for nil input")
}
// TestNilSlice_Map verifies that Map handles nil slices correctly
func TestNilSlice_Map(t *testing.T) {
var nilSlice []int
mapper := Map(func(v int) string {
return fmt.Sprintf("%d", v)
})
result := mapper(nilSlice)
assert.NotNil(t, result, "Map should return non-nil slice")
assert.Equal(t, 0, len(result), "Map should return empty slice for nil input")
}
// TestNilSlice_MapRef verifies that MapRef handles nil slices correctly
func TestNilSlice_MapRef(t *testing.T) {
var nilSlice []int
mapper := MapRef(func(v *int) string {
return fmt.Sprintf("%d", *v)
})
result := mapper(nilSlice)
assert.NotNil(t, result, "MapRef should return non-nil slice")
assert.Equal(t, 0, len(result), "MapRef should return empty slice for nil input")
}
// TestNilSlice_MapWithIndex verifies that MapWithIndex handles nil slices correctly
func TestNilSlice_MapWithIndex(t *testing.T) {
var nilSlice []int
mapper := MapWithIndex(func(i int, v int) string {
return fmt.Sprintf("%d:%d", i, v)
})
result := mapper(nilSlice)
assert.NotNil(t, result, "MapWithIndex should return non-nil slice")
assert.Equal(t, 0, len(result), "MapWithIndex should return empty slice for nil input")
}
// TestNilSlice_Filter verifies that Filter handles nil slices correctly
func TestNilSlice_Filter(t *testing.T) {
var nilSlice []int
filter := Filter(func(v int) bool {
return v > 0
})
result := filter(nilSlice)
assert.NotNil(t, result, "Filter should return non-nil slice")
assert.Equal(t, 0, len(result), "Filter should return empty slice for nil input")
}
// TestNilSlice_FilterWithIndex verifies that FilterWithIndex handles nil slices correctly
func TestNilSlice_FilterWithIndex(t *testing.T) {
var nilSlice []int
filter := FilterWithIndex(func(i int, v int) bool {
return v > 0
})
result := filter(nilSlice)
assert.NotNil(t, result, "FilterWithIndex should return non-nil slice")
assert.Equal(t, 0, len(result), "FilterWithIndex should return empty slice for nil input")
}
// TestNilSlice_FilterRef verifies that FilterRef handles nil slices correctly
func TestNilSlice_FilterRef(t *testing.T) {
var nilSlice []int
filter := FilterRef(func(v *int) bool {
return *v > 0
})
result := filter(nilSlice)
assert.NotNil(t, result, "FilterRef should return non-nil slice")
assert.Equal(t, 0, len(result), "FilterRef should return empty slice for nil input")
}
// TestNilSlice_MonadFilterMap verifies that MonadFilterMap handles nil slices correctly
func TestNilSlice_MonadFilterMap(t *testing.T) {
var nilSlice []int
result := MonadFilterMap(nilSlice, func(v int) O.Option[string] {
return O.Some(fmt.Sprintf("%d", v))
})
assert.NotNil(t, result, "MonadFilterMap should return non-nil slice")
assert.Equal(t, 0, len(result), "MonadFilterMap should return empty slice for nil input")
}
// TestNilSlice_MonadFilterMapWithIndex verifies that MonadFilterMapWithIndex handles nil slices correctly
func TestNilSlice_MonadFilterMapWithIndex(t *testing.T) {
var nilSlice []int
result := MonadFilterMapWithIndex(nilSlice, func(i int, v int) O.Option[string] {
return O.Some(fmt.Sprintf("%d:%d", i, v))
})
assert.NotNil(t, result, "MonadFilterMapWithIndex should return non-nil slice")
assert.Equal(t, 0, len(result), "MonadFilterMapWithIndex should return empty slice for nil input")
}
// TestNilSlice_FilterMap verifies that FilterMap handles nil slices correctly
func TestNilSlice_FilterMap(t *testing.T) {
var nilSlice []int
filter := FilterMap(func(v int) O.Option[string] {
return O.Some(fmt.Sprintf("%d", v))
})
result := filter(nilSlice)
assert.NotNil(t, result, "FilterMap should return non-nil slice")
assert.Equal(t, 0, len(result), "FilterMap should return empty slice for nil input")
}
// TestNilSlice_FilterMapWithIndex verifies that FilterMapWithIndex handles nil slices correctly
func TestNilSlice_FilterMapWithIndex(t *testing.T) {
var nilSlice []int
filter := FilterMapWithIndex(func(i int, v int) O.Option[string] {
return O.Some(fmt.Sprintf("%d:%d", i, v))
})
result := filter(nilSlice)
assert.NotNil(t, result, "FilterMapWithIndex should return non-nil slice")
assert.Equal(t, 0, len(result), "FilterMapWithIndex should return empty slice for nil input")
}
// TestNilSlice_MonadReduce verifies that MonadReduce handles nil slices correctly
func TestNilSlice_MonadReduce(t *testing.T) {
var nilSlice []int
result := MonadReduce(nilSlice, func(acc int, v int) int {
return acc + v
}, 10)
assert.Equal(t, 10, result, "MonadReduce should return initial value for nil slice")
}
// TestNilSlice_MonadReduceWithIndex verifies that MonadReduceWithIndex handles nil slices correctly
func TestNilSlice_MonadReduceWithIndex(t *testing.T) {
var nilSlice []int
result := MonadReduceWithIndex(nilSlice, func(i int, acc int, v int) int {
return acc + v
}, 10)
assert.Equal(t, 10, result, "MonadReduceWithIndex should return initial value for nil slice")
}
// TestNilSlice_Reduce verifies that Reduce handles nil slices correctly
func TestNilSlice_Reduce(t *testing.T) {
var nilSlice []int
reducer := Reduce(func(acc int, v int) int {
return acc + v
}, 10)
result := reducer(nilSlice)
assert.Equal(t, 10, result, "Reduce should return initial value for nil slice")
}
// TestNilSlice_ReduceWithIndex verifies that ReduceWithIndex handles nil slices correctly
func TestNilSlice_ReduceWithIndex(t *testing.T) {
var nilSlice []int
reducer := ReduceWithIndex(func(i int, acc int, v int) int {
return acc + v
}, 10)
result := reducer(nilSlice)
assert.Equal(t, 10, result, "ReduceWithIndex should return initial value for nil slice")
}
// TestNilSlice_ReduceRight verifies that ReduceRight handles nil slices correctly
func TestNilSlice_ReduceRight(t *testing.T) {
var nilSlice []int
reducer := ReduceRight(func(v int, acc int) int {
return acc + v
}, 10)
result := reducer(nilSlice)
assert.Equal(t, 10, result, "ReduceRight should return initial value for nil slice")
}
// TestNilSlice_ReduceRightWithIndex verifies that ReduceRightWithIndex handles nil slices correctly
func TestNilSlice_ReduceRightWithIndex(t *testing.T) {
var nilSlice []int
reducer := ReduceRightWithIndex(func(i int, v int, acc int) int {
return acc + v
}, 10)
result := reducer(nilSlice)
assert.Equal(t, 10, result, "ReduceRightWithIndex should return initial value for nil slice")
}
// TestNilSlice_ReduceRef verifies that ReduceRef handles nil slices correctly
func TestNilSlice_ReduceRef(t *testing.T) {
var nilSlice []int
reducer := ReduceRef(func(acc int, v *int) int {
return acc + *v
}, 10)
result := reducer(nilSlice)
assert.Equal(t, 10, result, "ReduceRef should return initial value for nil slice")
}
// TestNilSlice_Append verifies that Append handles nil slices correctly
func TestNilSlice_Append(t *testing.T) {
var nilSlice []int
result := Append(nilSlice, 42)
assert.NotNil(t, result, "Append should return non-nil slice")
assert.Equal(t, 1, len(result), "Append should create slice with one element")
assert.Equal(t, 42, result[0], "Append should add element correctly")
}
// TestNilSlice_MonadChain verifies that MonadChain handles nil slices correctly
func TestNilSlice_MonadChain(t *testing.T) {
var nilSlice []int
result := MonadChain(nilSlice, func(v int) []string {
return []string{fmt.Sprintf("%d", v)}
})
assert.NotNil(t, result, "MonadChain should return non-nil slice")
assert.Equal(t, 0, len(result), "MonadChain should return empty slice for nil input")
}
// TestNilSlice_Chain verifies that Chain handles nil slices correctly
func TestNilSlice_Chain(t *testing.T) {
var nilSlice []int
chain := Chain(func(v int) []string {
return []string{fmt.Sprintf("%d", v)}
})
result := chain(nilSlice)
assert.NotNil(t, result, "Chain should return non-nil slice")
assert.Equal(t, 0, len(result), "Chain should return empty slice for nil input")
}
// TestNilSlice_MonadAp verifies that MonadAp handles nil slices correctly
func TestNilSlice_MonadAp(t *testing.T) {
var nilFuncs []func(int) string
var nilValues []int
// nil functions, nil values
result1 := MonadAp(nilFuncs, nilValues)
assert.NotNil(t, result1, "MonadAp should return non-nil slice")
assert.Equal(t, 0, len(result1), "MonadAp should return empty slice for nil inputs")
// nil functions, non-nil values
nonNilValues := []int{1, 2, 3}
result2 := MonadAp(nilFuncs, nonNilValues)
assert.NotNil(t, result2, "MonadAp should return non-nil slice")
assert.Equal(t, 0, len(result2), "MonadAp should return empty slice when functions are nil")
// non-nil functions, nil values
nonNilFuncs := []func(int) string{func(v int) string { return fmt.Sprintf("%d", v) }}
result3 := MonadAp(nonNilFuncs, nilValues)
assert.NotNil(t, result3, "MonadAp should return non-nil slice")
assert.Equal(t, 0, len(result3), "MonadAp should return empty slice when values are nil")
}
// TestNilSlice_Ap verifies that Ap handles nil slices correctly
func TestNilSlice_Ap(t *testing.T) {
var nilValues []int
ap := Ap[string](nilValues)
var nilFuncs []func(int) string
result := ap(nilFuncs)
assert.NotNil(t, result, "Ap should return non-nil slice")
assert.Equal(t, 0, len(result), "Ap should return empty slice for nil inputs")
}
// TestNilSlice_Head verifies that Head handles nil slices correctly
func TestNilSlice_Head(t *testing.T) {
var nilSlice []int
result := Head(nilSlice)
assert.True(t, O.IsNone(result), "Head should return None for nil slice")
}
// TestNilSlice_First verifies that First handles nil slices correctly
func TestNilSlice_First(t *testing.T) {
var nilSlice []int
result := First(nilSlice)
assert.True(t, O.IsNone(result), "First should return None for nil slice")
}
// TestNilSlice_Last verifies that Last handles nil slices correctly
func TestNilSlice_Last(t *testing.T) {
var nilSlice []int
result := Last(nilSlice)
assert.True(t, O.IsNone(result), "Last should return None for nil slice")
}
// TestNilSlice_Tail verifies that Tail handles nil slices correctly
func TestNilSlice_Tail(t *testing.T) {
var nilSlice []int
result := Tail(nilSlice)
assert.True(t, O.IsNone(result), "Tail should return None for nil slice")
}
// TestNilSlice_Flatten verifies that Flatten handles nil slices correctly
func TestNilSlice_Flatten(t *testing.T) {
var nilSlice [][]int
result := Flatten(nilSlice)
assert.NotNil(t, result, "Flatten should return non-nil slice")
assert.Equal(t, 0, len(result), "Flatten should return empty slice for nil input")
}
// TestNilSlice_Lookup verifies that Lookup handles nil slices correctly
func TestNilSlice_Lookup(t *testing.T) {
var nilSlice []int
lookup := Lookup[int](0)
result := lookup(nilSlice)
assert.True(t, O.IsNone(result), "Lookup should return None for nil slice")
}
// TestNilSlice_Size verifies that Size handles nil slices correctly
func TestNilSlice_Size(t *testing.T) {
var nilSlice []int
result := Size(nilSlice)
assert.Equal(t, 0, result, "Size should return 0 for nil slice")
}
// TestNilSlice_MonadPartition verifies that MonadPartition handles nil slices correctly
func TestNilSlice_MonadPartition(t *testing.T) {
var nilSlice []int
result := MonadPartition(nilSlice, func(v int) bool {
return v > 0
})
left := P.Head(result)
right := P.Tail(result)
assert.NotNil(t, left, "MonadPartition left should return non-nil slice")
assert.NotNil(t, right, "MonadPartition right should return non-nil slice")
assert.Equal(t, 0, len(left), "MonadPartition left should be empty for nil input")
assert.Equal(t, 0, len(right), "MonadPartition right should be empty for nil input")
}
// TestNilSlice_Partition verifies that Partition handles nil slices correctly
func TestNilSlice_Partition(t *testing.T) {
var nilSlice []int
partition := Partition(func(v int) bool {
return v > 0
})
result := partition(nilSlice)
left := P.Head(result)
right := P.Tail(result)
assert.NotNil(t, left, "Partition left should return non-nil slice")
assert.NotNil(t, right, "Partition right should return non-nil slice")
assert.Equal(t, 0, len(left), "Partition left should be empty for nil input")
assert.Equal(t, 0, len(right), "Partition right should be empty for nil input")
}
// TestNilSlice_IsNil verifies that IsNil handles nil slices correctly
func TestNilSlice_IsNil(t *testing.T) {
var nilSlice []int
assert.True(t, IsNil(nilSlice), "IsNil should return true for nil slice")
nonNilSlice := []int{}
assert.False(t, IsNil(nonNilSlice), "IsNil should return false for non-nil empty slice")
}
// TestNilSlice_IsNonNil verifies that IsNonNil handles nil slices correctly
func TestNilSlice_IsNonNil(t *testing.T) {
var nilSlice []int
assert.False(t, IsNonNil(nilSlice), "IsNonNil should return false for nil slice")
nonNilSlice := []int{}
assert.True(t, IsNonNil(nonNilSlice), "IsNonNil should return true for non-nil empty slice")
}
// TestNilSlice_Copy verifies that Copy handles nil slices correctly
func TestNilSlice_Copy(t *testing.T) {
var nilSlice []int
result := Copy(nilSlice)
assert.NotNil(t, result, "Copy should return non-nil slice")
assert.Equal(t, 0, len(result), "Copy should return empty slice for nil input")
}
// TestNilSlice_FoldMap verifies that FoldMap handles nil slices correctly
func TestNilSlice_FoldMap(t *testing.T) {
var nilSlice []int
monoid := S.Monoid
foldMap := FoldMap[int](monoid)(func(v int) string {
return fmt.Sprintf("%d", v)
})
result := foldMap(nilSlice)
assert.Equal(t, "", result, "FoldMap should return empty value for nil slice")
}
// TestNilSlice_FoldMapWithIndex verifies that FoldMapWithIndex handles nil slices correctly
func TestNilSlice_FoldMapWithIndex(t *testing.T) {
var nilSlice []int
monoid := S.Monoid
foldMap := FoldMapWithIndex[int](monoid)(func(i int, v int) string {
return fmt.Sprintf("%d:%d", i, v)
})
result := foldMap(nilSlice)
assert.Equal(t, "", result, "FoldMapWithIndex should return empty value for nil slice")
}
// TestNilSlice_Fold verifies that Fold handles nil slices correctly
func TestNilSlice_Fold(t *testing.T) {
var nilSlice []string
monoid := S.Monoid
fold := Fold[string](monoid)
result := fold(nilSlice)
assert.Equal(t, "", result, "Fold should return empty value for nil slice")
}
// TestNilSlice_Concat verifies that Concat handles nil slices correctly
func TestNilSlice_Concat(t *testing.T) {
var nilSlice []int
nonNilSlice := []int{1, 2, 3}
// nil concat non-nil
concat1 := Concat(nonNilSlice)
result1 := concat1(nilSlice)
assert.Equal(t, nonNilSlice, result1, "nil concat non-nil should return non-nil slice")
// non-nil concat nil
concat2 := Concat(nilSlice)
result2 := concat2(nonNilSlice)
assert.Equal(t, nonNilSlice, result2, "non-nil concat nil should return non-nil slice")
// nil concat nil
concat3 := Concat(nilSlice)
result3 := concat3(nilSlice)
assert.Nil(t, result3, "nil concat nil should return nil")
}
// TestNilSlice_MonadFlap verifies that MonadFlap handles nil slices correctly
func TestNilSlice_MonadFlap(t *testing.T) {
var nilSlice []func(int) string
result := MonadFlap(nilSlice, 42)
assert.NotNil(t, result, "MonadFlap should return non-nil slice")
assert.Equal(t, 0, len(result), "MonadFlap should return empty slice for nil input")
}
// TestNilSlice_Flap verifies that Flap handles nil slices correctly
func TestNilSlice_Flap(t *testing.T) {
var nilSlice []func(int) string
flap := Flap[string, int](42)
result := flap(nilSlice)
assert.NotNil(t, result, "Flap should return non-nil slice")
assert.Equal(t, 0, len(result), "Flap should return empty slice for nil input")
}
// TestNilSlice_Reverse verifies that Reverse handles nil slices correctly
func TestNilSlice_Reverse(t *testing.T) {
var nilSlice []int
result := Reverse(nilSlice)
assert.Nil(t, result, "Reverse should return nil for nil slice")
}
// TestNilSlice_Extend verifies that Extend handles nil slices correctly
func TestNilSlice_Extend(t *testing.T) {
var nilSlice []int
extend := Extend(func(as []int) string {
return fmt.Sprintf("%v", as)
})
result := extend(nilSlice)
assert.NotNil(t, result, "Extend should return non-nil slice")
assert.Equal(t, 0, len(result), "Extend should return empty slice for nil input")
}
// TestNilSlice_Empty verifies that Empty creates an empty non-nil slice
func TestNilSlice_Empty(t *testing.T) {
result := Empty[int]()
assert.NotNil(t, result, "Empty should return non-nil slice")
assert.Equal(t, 0, len(result), "Empty should return empty slice")
assert.False(t, IsNil(result), "Empty should not return nil slice")
}
// TestNilSlice_Zero verifies that Zero creates an empty non-nil slice
func TestNilSlice_Zero(t *testing.T) {
result := Zero[int]()
assert.NotNil(t, result, "Zero should return non-nil slice")
assert.Equal(t, 0, len(result), "Zero should return empty slice")
assert.False(t, IsNil(result), "Zero should not return nil slice")
}
// TestNilSlice_ConstNil verifies that ConstNil returns a nil slice
func TestNilSlice_ConstNil(t *testing.T) {
result := ConstNil[int]()
assert.Nil(t, result, "ConstNil should return nil slice")
assert.True(t, IsNil(result), "ConstNil should return nil slice")
}
// TestNilSlice_Of verifies that Of creates a proper singleton slice
func TestNilSlice_Of(t *testing.T) {
result := Of(42)
assert.NotNil(t, result, "Of should return non-nil slice")
assert.Equal(t, 1, len(result), "Of should create slice with one element")
assert.Equal(t, 42, result[0], "Of should set value correctly")
}

View File

@@ -198,11 +198,228 @@ func TestFilterMap(t *testing.T) {
}
func TestFoldMap(t *testing.T) {
src := From("a", "b", "c")
t.Run("FoldMap with 0 items", func(t *testing.T) {
empty := []int{}
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMap[int](sumMonoid)(N.Mul(2))
result := foldMap(empty)
assert.Equal(t, 0, result, "FoldMap should return monoid empty for 0 items")
})
fold := FoldMap[string](S.Monoid)(strings.ToUpper)
t.Run("FoldMap with 1 item", func(t *testing.T) {
single := From(5)
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMap[int](sumMonoid)(N.Mul(2))
result := foldMap(single)
assert.Equal(t, 10, result, "FoldMap should map and return single item")
})
assert.Equal(t, "ABC", fold(src))
t.Run("FoldMap with 2 items", func(t *testing.T) {
two := From(3, 4)
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMap[int](sumMonoid)(N.Mul(2))
result := foldMap(two)
assert.Equal(t, 14, result, "FoldMap should map and fold 2 items: (3*2) + (4*2) = 14")
})
t.Run("FoldMap with many items", func(t *testing.T) {
many := From(1, 2, 3, 4, 5)
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMap[int](sumMonoid)(N.Mul(2))
result := foldMap(many)
assert.Equal(t, 30, result, "FoldMap should map and fold many items: (1*2) + (2*2) + (3*2) + (4*2) + (5*2) = 30")
})
t.Run("FoldMap with string concatenation - 0 items", func(t *testing.T) {
empty := []string{}
fold := FoldMap[string](S.Monoid)(strings.ToUpper)
result := fold(empty)
assert.Equal(t, "", result, "FoldMap should return empty string for 0 items")
})
t.Run("FoldMap with string concatenation - 1 item", func(t *testing.T) {
single := From("a")
fold := FoldMap[string](S.Monoid)(strings.ToUpper)
result := fold(single)
assert.Equal(t, "A", result, "FoldMap should map single string")
})
t.Run("FoldMap with string concatenation - 2 items", func(t *testing.T) {
two := From("a", "b")
fold := FoldMap[string](S.Monoid)(strings.ToUpper)
result := fold(two)
assert.Equal(t, "AB", result, "FoldMap should map and concatenate 2 strings")
})
t.Run("FoldMap with string concatenation - many items", func(t *testing.T) {
many := From("a", "b", "c", "d", "e")
fold := FoldMap[string](S.Monoid)(strings.ToUpper)
result := fold(many)
assert.Equal(t, "ABCDE", result, "FoldMap should map and concatenate many strings")
})
}
func TestFold(t *testing.T) {
t.Run("Fold with 0 items", func(t *testing.T) {
empty := []int{}
sumMonoid := N.MonoidSum[int]()
fold := Fold[int](sumMonoid)
result := fold(empty)
assert.Equal(t, 0, result, "Fold should return monoid empty for 0 items")
})
t.Run("Fold with 1 item", func(t *testing.T) {
single := From(42)
sumMonoid := N.MonoidSum[int]()
fold := Fold[int](sumMonoid)
result := fold(single)
assert.Equal(t, 42, result, "Fold should return single item")
})
t.Run("Fold with 2 items", func(t *testing.T) {
two := From(10, 20)
sumMonoid := N.MonoidSum[int]()
fold := Fold[int](sumMonoid)
result := fold(two)
assert.Equal(t, 30, result, "Fold should combine 2 items: 10 + 20 = 30")
})
t.Run("Fold with many items", func(t *testing.T) {
many := From(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
sumMonoid := N.MonoidSum[int]()
fold := Fold[int](sumMonoid)
result := fold(many)
assert.Equal(t, 55, result, "Fold should combine many items: 1+2+3+4+5+6+7+8+9+10 = 55")
})
t.Run("Fold with string concatenation - 0 items", func(t *testing.T) {
empty := []string{}
fold := Fold[string](S.Monoid)
result := fold(empty)
assert.Equal(t, "", result, "Fold should return empty string for 0 items")
})
t.Run("Fold with string concatenation - 1 item", func(t *testing.T) {
single := From("hello")
fold := Fold[string](S.Monoid)
result := fold(single)
assert.Equal(t, "hello", result, "Fold should return single string")
})
t.Run("Fold with string concatenation - 2 items", func(t *testing.T) {
two := From("hello", "world")
fold := Fold[string](S.Monoid)
result := fold(two)
assert.Equal(t, "helloworld", result, "Fold should concatenate 2 strings")
})
t.Run("Fold with string concatenation - many items", func(t *testing.T) {
many := From("a", "b", "c", "d", "e", "f")
fold := Fold[string](S.Monoid)
result := fold(many)
assert.Equal(t, "abcdef", result, "Fold should concatenate many strings")
})
t.Run("Fold with product monoid - 0 items", func(t *testing.T) {
empty := []int{}
productMonoid := N.MonoidProduct[int]()
fold := Fold[int](productMonoid)
result := fold(empty)
assert.Equal(t, 1, result, "Fold should return monoid empty (1) for product with 0 items")
})
t.Run("Fold with product monoid - 1 item", func(t *testing.T) {
single := From(7)
productMonoid := N.MonoidProduct[int]()
fold := Fold[int](productMonoid)
result := fold(single)
assert.Equal(t, 7, result, "Fold should return single item for product")
})
t.Run("Fold with product monoid - 2 items", func(t *testing.T) {
two := From(3, 4)
productMonoid := N.MonoidProduct[int]()
fold := Fold[int](productMonoid)
result := fold(two)
assert.Equal(t, 12, result, "Fold should multiply 2 items: 3 * 4 = 12")
})
t.Run("Fold with product monoid - many items", func(t *testing.T) {
many := From(2, 3, 4, 5)
productMonoid := N.MonoidProduct[int]()
fold := Fold[int](productMonoid)
result := fold(many)
assert.Equal(t, 120, result, "Fold should multiply many items: 2*3*4*5 = 120")
})
}
func TestFoldMapWithIndex(t *testing.T) {
t.Run("FoldMapWithIndex with 0 items", func(t *testing.T) {
empty := []int{}
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMapWithIndex[int](sumMonoid)(func(i, x int) int { return i + x })
result := foldMap(empty)
assert.Equal(t, 0, result, "FoldMapWithIndex should return monoid empty for 0 items")
})
t.Run("FoldMapWithIndex with 1 item", func(t *testing.T) {
single := From(10)
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMapWithIndex[int](sumMonoid)(func(i, x int) int { return i + x })
result := foldMap(single)
assert.Equal(t, 10, result, "FoldMapWithIndex should map with index: 0 + 10 = 10")
})
t.Run("FoldMapWithIndex with 2 items", func(t *testing.T) {
two := From(10, 20)
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMapWithIndex[int](sumMonoid)(func(i, x int) int { return i + x })
result := foldMap(two)
assert.Equal(t, 31, result, "FoldMapWithIndex should map with indices: (0+10) + (1+20) = 31")
})
t.Run("FoldMapWithIndex with many items", func(t *testing.T) {
many := From(5, 10, 15, 20)
sumMonoid := N.MonoidSum[int]()
foldMap := FoldMapWithIndex[int](sumMonoid)(func(i, x int) int { return i * x })
result := foldMap(many)
assert.Equal(t, 100, result, "FoldMapWithIndex should map with indices: (0*5) + (1*10) + (2*15) + (3*20) = 100")
})
t.Run("FoldMapWithIndex with string concatenation - 0 items", func(t *testing.T) {
empty := []string{}
foldMap := FoldMapWithIndex[string](S.Monoid)(func(i int, s string) string {
return fmt.Sprintf("%d:%s", i, s)
})
result := foldMap(empty)
assert.Equal(t, "", result, "FoldMapWithIndex should return empty string for 0 items")
})
t.Run("FoldMapWithIndex with string concatenation - 1 item", func(t *testing.T) {
single := From("a")
foldMap := FoldMapWithIndex[string](S.Monoid)(func(i int, s string) string {
return fmt.Sprintf("%d:%s", i, s)
})
result := foldMap(single)
assert.Equal(t, "0:a", result, "FoldMapWithIndex should format single item with index")
})
t.Run("FoldMapWithIndex with string concatenation - 2 items", func(t *testing.T) {
two := From("a", "b")
foldMap := FoldMapWithIndex[string](S.Monoid)(func(i int, s string) string {
return fmt.Sprintf("%d:%s,", i, s)
})
result := foldMap(two)
assert.Equal(t, "0:a,1:b,", result, "FoldMapWithIndex should format 2 items with indices")
})
t.Run("FoldMapWithIndex with string concatenation - many items", func(t *testing.T) {
many := From("a", "b", "c", "d")
foldMap := FoldMapWithIndex[string](S.Monoid)(func(i int, s string) string {
return fmt.Sprintf("[%d]%s", i, s)
})
result := foldMap(many)
assert.Equal(t, "[0]a[1]b[2]c[3]d", result, "FoldMapWithIndex should format many items with indices")
})
}
func ExampleFoldMap() {
@@ -767,6 +984,25 @@ func TestExtendUseCases(t *testing.T) {
// TestConcat tests the Concat function
func TestConcat(t *testing.T) {
t.Run("Semantic: Concat(b)(a) produces [a... b...]", func(t *testing.T) {
a := []int{1, 2, 3}
b := []int{4, 5, 6}
// Concat(b)(a) should produce [a... b...]
result := Concat(b)(a)
expected := []int{1, 2, 3, 4, 5, 6}
assert.Equal(t, expected, result, "Concat(b)(a) should produce [a... b...]")
// Verify order: a's elements come first, then b's elements
assert.Equal(t, a[0], result[0], "First element should be from a")
assert.Equal(t, a[1], result[1], "Second element should be from a")
assert.Equal(t, a[2], result[2], "Third element should be from a")
assert.Equal(t, b[0], result[3], "Fourth element should be from b")
assert.Equal(t, b[1], result[4], "Fifth element should be from b")
assert.Equal(t, b[2], result[5], "Sixth element should be from b")
})
t.Run("Concat two non-empty arrays", func(t *testing.T) {
base := []int{1, 2, 3}
toAppend := []int{4, 5, 6}
@@ -870,6 +1106,54 @@ func TestConcat(t *testing.T) {
expected := []int{1, 2, 3}
assert.Equal(t, expected, result)
})
t.Run("Explicit append semantic demonstration", func(t *testing.T) {
// Given a base array
base := []string{"A", "B", "C"}
// And a suffix to append
suffix := []string{"D", "E", "F"}
// When we apply Concat(suffix) to base
appendSuffix := Concat(suffix)
result := appendSuffix(base)
// Then the result should be base followed by suffix
expected := []string{"A", "B", "C", "D", "E", "F"}
assert.Equal(t, expected, result)
// And the base should be unchanged
assert.Equal(t, []string{"A", "B", "C"}, base)
// And the suffix should be unchanged
assert.Equal(t, []string{"D", "E", "F"}, suffix)
})
t.Run("Append semantic with different types", func(t *testing.T) {
// Integers
intResult := Concat([]int{4, 5})([]int{1, 2, 3})
assert.Equal(t, []int{1, 2, 3, 4, 5}, intResult)
// Strings
strResult := Concat([]string{"world"})([]string{"hello"})
assert.Equal(t, []string{"hello", "world"}, strResult)
// Floats
floatResult := Concat([]float64{3.3, 4.4})([]float64{1.1, 2.2})
assert.Equal(t, []float64{1.1, 2.2, 3.3, 4.4}, floatResult)
})
t.Run("Append semantic in pipeline", func(t *testing.T) {
// Start with [1, 2, 3]
// Append [4, 5] to get [1, 2, 3, 4, 5]
// Append [6, 7] to get [1, 2, 3, 4, 5, 6, 7]
result := F.Pipe2(
[]int{1, 2, 3},
Concat([]int{4, 5}),
Concat([]int{6, 7}),
)
expected := []int{1, 2, 3, 4, 5, 6, 7}
assert.Equal(t, expected, result)
})
}
// TestConcatComposition tests Concat with other array operations

View File

@@ -323,34 +323,49 @@ func Clone[AS ~[]A, A any](f func(A) A) func(as AS) AS {
}
func FoldMap[AS ~[]A, A, B any](m M.Monoid[B]) func(func(A) B) func(AS) B {
empty := m.Empty()
concat := m.Concat
return func(f func(A) B) func(AS) B {
return func(as AS) B {
return array.Reduce(as, func(cur B, a A) B {
return concat(cur, f(a))
}, empty)
switch len(as) {
case 0:
return m.Empty()
case 1:
return f(as[0])
case 2:
return concat(f(as[0]), f(as[1]))
default:
return array.Reduce(as[1:], func(cur B, a A) B {
return concat(cur, f(a))
}, f(as[0]))
}
}
}
}
func FoldMapWithIndex[AS ~[]A, A, B any](m M.Monoid[B]) func(func(int, A) B) func(AS) B {
empty := m.Empty()
concat := m.Concat
return func(f func(int, A) B) func(AS) B {
return func(as AS) B {
return array.ReduceWithIndex(as, func(idx int, cur B, a A) B {
return concat(cur, f(idx, a))
}, empty)
}, m.Empty())
}
}
}
func Fold[AS ~[]A, A any](m M.Monoid[A]) func(AS) A {
empty := m.Empty()
concat := m.Concat
return func(as AS) A {
return array.Reduce(as, concat, empty)
switch len(as) {
case 0:
return m.Empty()
case 1:
return as[0]
case 2:
return concat(as[0], as[1])
default:
return array.Reduce(as[1:], concat, as[0])
}
}
}

View File

@@ -25,7 +25,7 @@ func MonadSequence[HKTA, HKTRA any](
fof func(HKTA) HKTRA,
m M.Monoid[HKTRA],
ma []HKTA) HKTRA {
return array.MonadSequence(fof, m.Empty(), m.Concat, ma)
return array.MonadSequence(fof, m.Empty, m.Concat, ma)
}
// Sequence takes an array where elements are HKT<A> (higher kinded type) and,
@@ -67,7 +67,7 @@ func Sequence[HKTA, HKTRA any](
fof func(HKTA) HKTRA,
m M.Monoid[HKTRA],
) func([]HKTA) HKTRA {
return array.Sequence[[]HKTA](fof, m.Empty(), m.Concat)
return array.Sequence[[]HKTA](fof, m.Empty, m.Concat)
}
// ArrayOption returns a function to convert a sequence of options into an option of a sequence.

View File

@@ -194,6 +194,25 @@ func ArrayNotEmpty[T any](arr []T) Reader {
}
}
// ArrayEmpty checks if an array is empty.
//
// This is the complement of ArrayNotEmpty, asserting that a slice has no elements.
//
// Example:
//
// func TestArrayEmpty(t *testing.T) {
// empty := []int{}
// assert.ArrayEmpty(empty)(t) // Passes
//
// numbers := []int{1, 2, 3}
// assert.ArrayEmpty(numbers)(t) // Fails
// }
func ArrayEmpty[T any](arr []T) Reader {
return func(t *testing.T) bool {
return assert.Empty(t, arr)
}
}
// RecordNotEmpty checks if a map is not empty.
//
// Example:
@@ -211,6 +230,25 @@ func RecordNotEmpty[K comparable, T any](mp map[K]T) Reader {
}
}
// RecordEmpty checks if a map is empty.
//
// This is the complement of RecordNotEmpty, asserting that a map has no key-value pairs.
//
// Example:
//
// func TestRecordEmpty(t *testing.T) {
// empty := map[string]int{}
// assert.RecordEmpty(empty)(t) // Passes
//
// config := map[string]int{"timeout": 30}
// assert.RecordEmpty(config)(t) // Fails
// }
func RecordEmpty[K comparable, T any](mp map[K]T) Reader {
return func(t *testing.T) bool {
return assert.Empty(t, mp)
}
}
// StringNotEmpty checks if a string is not empty.
//
// Example:
@@ -504,15 +542,7 @@ func AllOf(readers []Reader) Reader {
//
//go:inline
func RunAll(testcases map[string]Reader) Reader {
return func(t *testing.T) bool {
current := true
for k, r := range testcases {
current = current && t.Run(k, func(t1 *testing.T) {
r(t1)
})
}
return current
}
return SequenceRecord(testcases)
}
// Local transforms a Reader that works on type R1 into a Reader that works on type R2,

View File

@@ -85,6 +85,33 @@ func TestArrayNotEmpty(t *testing.T) {
})
}
func TestArrayEmpty(t *testing.T) {
t.Run("should pass for empty array", func(t *testing.T) {
arr := []int{}
result := ArrayEmpty(arr)(t)
if !result {
t.Error("Expected ArrayEmpty to pass for empty array")
}
})
t.Run("should fail for non-empty array", func(t *testing.T) {
mockT := &testing.T{}
arr := []int{1, 2, 3}
result := ArrayEmpty(arr)(mockT)
if result {
t.Error("Expected ArrayEmpty to fail for non-empty array")
}
})
t.Run("should work with different types", func(t *testing.T) {
strArr := []string{}
result := ArrayEmpty(strArr)(t)
if !result {
t.Error("Expected ArrayEmpty to pass for empty string array")
}
})
}
func TestRecordNotEmpty(t *testing.T) {
t.Run("should pass for non-empty map", func(t *testing.T) {
mp := map[string]int{"a": 1, "b": 2}
@@ -131,6 +158,33 @@ func TestArrayLength(t *testing.T) {
})
}
func TestRecordEmpty(t *testing.T) {
t.Run("should pass for empty map", func(t *testing.T) {
mp := map[string]int{}
result := RecordEmpty(mp)(t)
if !result {
t.Error("Expected RecordEmpty to pass for empty map")
}
})
t.Run("should fail for non-empty map", func(t *testing.T) {
mockT := &testing.T{}
mp := map[string]int{"a": 1, "b": 2}
result := RecordEmpty(mp)(mockT)
if result {
t.Error("Expected RecordEmpty to fail for non-empty map")
}
})
t.Run("should work with different key-value types", func(t *testing.T) {
mp := map[int]string{}
result := RecordEmpty(mp)(t)
if !result {
t.Error("Expected RecordEmpty to pass for empty map with int keys")
}
})
}
func TestRecordLength(t *testing.T) {
t.Run("should pass when map length matches", func(t *testing.T) {
mp := map[string]int{"a": 1, "b": 2}
@@ -150,6 +204,33 @@ func TestRecordLength(t *testing.T) {
})
}
func TestStringNotEmpty(t *testing.T) {
t.Run("should pass for non-empty string", func(t *testing.T) {
str := "Hello, World!"
result := StringNotEmpty(str)(t)
if !result {
t.Error("Expected StringNotEmpty to pass for non-empty string")
}
})
t.Run("should fail for empty string", func(t *testing.T) {
mockT := &testing.T{}
str := ""
result := StringNotEmpty(str)(mockT)
if result {
t.Error("Expected StringNotEmpty to fail for empty string")
}
})
t.Run("should pass for string with whitespace", func(t *testing.T) {
str := " "
result := StringNotEmpty(str)(t)
if !result {
t.Error("Expected StringNotEmpty to pass for string with whitespace")
}
})
}
func TestStringLength(t *testing.T) {
t.Run("should pass when string length matches", func(t *testing.T) {
str := "hello"

122
v2/assert/from.go Normal file
View File

@@ -0,0 +1,122 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/result"
)
// FromReaderIOResult converts a ReaderIOResult[Reader] into a Reader.
//
// This function bridges the gap between context-aware, IO-based computations that may fail
// (ReaderIOResult) and the simpler Reader type used for test assertions. It executes the
// ReaderIOResult computation using the test's context, handles any potential errors by
// converting them to test failures via NoError, and returns the resulting Reader.
//
// The conversion process:
// 1. Executes the ReaderIOResult with the test context (t.Context())
// 2. Runs the resulting IO operation ()
// 3. Extracts the Result, converting errors to test failures using NoError
// 4. Returns a Reader that can be applied to *testing.T
//
// This is particularly useful when you have test assertions that need to:
// - Access context for cancellation or deadlines
// - Perform IO operations (file access, network calls, etc.)
// - Handle potential errors gracefully in tests
//
// Parameters:
// - ri: A ReaderIOResult that produces a Reader when given a context and executed
//
// Returns:
// - A Reader that can be directly applied to *testing.T for assertion
//
// Example:
//
// func TestWithContext(t *testing.T) {
// // Create a ReaderIOResult that performs an IO operation
// checkDatabase := func(ctx context.Context) func() result.Result[assert.Reader] {
// return func() result.Result[assert.Reader] {
// // Simulate database check
// if err := db.PingContext(ctx); err != nil {
// return result.Error[assert.Reader](err)
// }
// return result.Of[assert.Reader](assert.NoError(nil))
// }
// }
//
// // Convert to Reader and execute
// assertion := assert.FromReaderIOResult(checkDatabase)
// assertion(t)
// }
func FromReaderIOResult(ri ReaderIOResult[Reader]) Reader {
return func(t *testing.T) bool {
return F.Pipe1(
ri(t.Context())(),
result.GetOrElse(NoError),
)(t)
}
}
// FromReaderIO converts a ReaderIO[Reader] into a Reader.
//
// This function bridges the gap between context-aware, IO-based computations (ReaderIO)
// and the simpler Reader type used for test assertions. It executes the ReaderIO
// computation using the test's context and returns the resulting Reader.
//
// Unlike FromReaderIOResult, this function does not handle errors explicitly - it assumes
// the IO operation will succeed or that any errors are handled within the ReaderIO itself.
//
// The conversion process:
// 1. Executes the ReaderIO with the test context (t.Context())
// 2. Runs the resulting IO operation ()
// 3. Returns a Reader that can be applied to *testing.T
//
// This is particularly useful when you have test assertions that need to:
// - Access context for cancellation or deadlines
// - Perform IO operations that don't fail (or handle failures internally)
// - Integrate with context-aware testing utilities
//
// Parameters:
// - ri: A ReaderIO that produces a Reader when given a context and executed
//
// Returns:
// - A Reader that can be directly applied to *testing.T for assertion
//
// Example:
//
// func TestWithIO(t *testing.T) {
// // Create a ReaderIO that performs an IO operation
// logAndCheck := func(ctx context.Context) func() assert.Reader {
// return func() assert.Reader {
// // Log something using context
// logger.InfoContext(ctx, "Running test")
// // Return an assertion
// return assert.Equal(42)(computeValue())
// }
// }
//
// // Convert to Reader and execute
// assertion := assert.FromReaderIO(logAndCheck)
// assertion(t)
// }
func FromReaderIO(ri ReaderIO[Reader]) Reader {
return func(t *testing.T) bool {
return ri(t.Context())()(t)
}
}

383
v2/assert/from_test.go Normal file
View File

@@ -0,0 +1,383 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"context"
"errors"
"testing"
"github.com/IBM/fp-go/v2/result"
)
func TestFromReaderIOResult(t *testing.T) {
t.Run("should pass when ReaderIOResult returns success with passing assertion", func(t *testing.T) {
// Create a ReaderIOResult that returns a successful Reader
ri := func(ctx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
// Return a Reader that always passes
return result.Of(func(t *testing.T) bool {
return true
})
}
}
reader := FromReaderIOResult(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIOResult to pass when ReaderIOResult returns success")
}
})
t.Run("should pass when ReaderIOResult returns success with Equal assertion", func(t *testing.T) {
// Create a ReaderIOResult that returns a successful Equal assertion
ri := func(ctx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
return result.Of(Equal(42)(42))
}
}
reader := FromReaderIOResult(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIOResult to pass with Equal assertion")
}
})
t.Run("should fail when ReaderIOResult returns error", func(t *testing.T) {
mockT := &testing.T{}
// Create a ReaderIOResult that returns an error
ri := func(ctx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
return result.Left[Reader](errors.New("test error"))
}
}
reader := FromReaderIOResult(ri)
res := reader(mockT)
if res {
t.Error("Expected FromReaderIOResult to fail when ReaderIOResult returns error")
}
})
t.Run("should fail when ReaderIOResult returns success but assertion fails", func(t *testing.T) {
mockT := &testing.T{}
// Create a ReaderIOResult that returns a failing assertion
ri := func(ctx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
return result.Of(Equal(42)(43))
}
}
reader := FromReaderIOResult(ri)
res := reader(mockT)
if res {
t.Error("Expected FromReaderIOResult to fail when assertion fails")
}
})
t.Run("should use test context", func(t *testing.T) {
contextUsed := false
// Create a ReaderIOResult that checks if context is provided
ri := func(ctx context.Context) func() result.Result[Reader] {
if ctx != nil {
contextUsed = true
}
return func() result.Result[Reader] {
return result.Of(func(t *testing.T) bool {
return true
})
}
}
reader := FromReaderIOResult(ri)
reader(t)
if !contextUsed {
t.Error("Expected FromReaderIOResult to use test context")
}
})
t.Run("should work with NoError assertion", func(t *testing.T) {
// Create a ReaderIOResult that returns NoError assertion
ri := func(ctx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
return result.Of(NoError(nil))
}
}
reader := FromReaderIOResult(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIOResult to pass with NoError assertion")
}
})
t.Run("should work with complex assertions", func(t *testing.T) {
// Create a ReaderIOResult with multiple composed assertions
ri := func(ctx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
arr := []int{1, 2, 3}
assertions := AllOf([]Reader{
ArrayNotEmpty(arr),
ArrayLength[int](3)(arr),
ArrayContains(2)(arr),
})
return result.Of(assertions)
}
}
reader := FromReaderIOResult(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIOResult to pass with complex assertions")
}
})
}
func TestFromReaderIO(t *testing.T) {
t.Run("should pass when ReaderIO returns passing assertion", func(t *testing.T) {
// Create a ReaderIO that returns a Reader that always passes
ri := func(ctx context.Context) func() Reader {
return func() Reader {
return func(t *testing.T) bool {
return true
}
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass when ReaderIO returns passing assertion")
}
})
t.Run("should pass when ReaderIO returns Equal assertion", func(t *testing.T) {
// Create a ReaderIO that returns an Equal assertion
ri := func(ctx context.Context) func() Reader {
return func() Reader {
return Equal(42)(42)
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with Equal assertion")
}
})
t.Run("should fail when ReaderIO returns failing assertion", func(t *testing.T) {
mockT := &testing.T{}
// Create a ReaderIO that returns a failing assertion
ri := func(ctx context.Context) func() Reader {
return func() Reader {
return Equal(42)(43)
}
}
reader := FromReaderIO(ri)
res := reader(mockT)
if res {
t.Error("Expected FromReaderIO to fail when assertion fails")
}
})
t.Run("should use test context", func(t *testing.T) {
contextUsed := false
// Create a ReaderIO that checks if context is provided
ri := func(ctx context.Context) func() Reader {
if ctx != nil {
contextUsed = true
}
return func() Reader {
return func(t *testing.T) bool {
return true
}
}
}
reader := FromReaderIO(ri)
reader(t)
if !contextUsed {
t.Error("Expected FromReaderIO to use test context")
}
})
t.Run("should work with NoError assertion", func(t *testing.T) {
// Create a ReaderIO that returns NoError assertion
ri := func(ctx context.Context) func() Reader {
return func() Reader {
return NoError(nil)
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with NoError assertion")
}
})
t.Run("should work with Error assertion", func(t *testing.T) {
// Create a ReaderIO that returns Error assertion
ri := func(ctx context.Context) func() Reader {
return func() Reader {
return Error(errors.New("expected error"))
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with Error assertion")
}
})
t.Run("should work with complex assertions", func(t *testing.T) {
// Create a ReaderIO with multiple composed assertions
ri := func(ctx context.Context) func() Reader {
return func() Reader {
mp := map[string]int{"a": 1, "b": 2}
return AllOf([]Reader{
RecordNotEmpty(mp),
RecordLength[string, int](2)(mp),
ContainsKey[int]("a")(mp),
})
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with complex assertions")
}
})
t.Run("should work with string assertions", func(t *testing.T) {
// Create a ReaderIO with string assertions
ri := func(ctx context.Context) func() Reader {
return func() Reader {
str := "hello world"
return AllOf([]Reader{
StringNotEmpty(str),
StringLength[any, any](11)(str),
})
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with string assertions")
}
})
t.Run("should work with Result assertions", func(t *testing.T) {
// Create a ReaderIO with Result assertions
ri := func(ctx context.Context) func() Reader {
return func() Reader {
successResult := result.Of(42)
return Success(successResult)
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with Success assertion")
}
})
t.Run("should work with Failure assertion", func(t *testing.T) {
// Create a ReaderIO with Failure assertion
ri := func(ctx context.Context) func() Reader {
return func() Reader {
failureResult := result.Left[int](errors.New("test error"))
return Failure(failureResult)
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected FromReaderIO to pass with Failure assertion")
}
})
}
// TestFromReaderIOResultIntegration tests integration scenarios
func TestFromReaderIOResultIntegration(t *testing.T) {
t.Run("should work in a realistic scenario with context cancellation", func(t *testing.T) {
// Create a ReaderIOResult that uses the context
ri := func(testCtx context.Context) func() result.Result[Reader] {
return func() result.Result[Reader] {
// Check if context is valid
if testCtx == nil {
return result.Left[Reader](errors.New("context is nil"))
}
// Return a successful assertion
return result.Of(Equal("test")("test"))
}
}
// Use the actual testing.T from the subtest
reader := FromReaderIOResult(ri)
res := reader(t)
if !res {
t.Error("Expected integration test to pass")
}
})
}
// TestFromReaderIOIntegration tests integration scenarios
func TestFromReaderIOIntegration(t *testing.T) {
t.Run("should work in a realistic scenario with logging", func(t *testing.T) {
logCalled := false
// Create a ReaderIO that simulates logging
ri := func(ctx context.Context) func() Reader {
return func() Reader {
// Simulate logging with context
if ctx != nil {
logCalled = true
}
// Return an assertion
return Equal(100)(100)
}
}
reader := FromReaderIO(ri)
res := reader(t)
if !res {
t.Error("Expected integration test to pass")
}
if !logCalled {
t.Error("Expected logging to be called")
}
})
}

207
v2/assert/logger.go Normal file
View File

@@ -0,0 +1,207 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"testing"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/readerio"
)
// Logf creates a logging function that outputs formatted test messages using Go's testing.T.Logf.
//
// This function provides a functional programming approach to test logging, returning a
// [ReaderIO] that can be composed with other test operations. It's particularly useful
// for debugging tests, tracing execution flow, or documenting test behavior without
// affecting test outcomes.
//
// The function uses a curried design pattern:
// 1. First, you provide a format string (prefix) with format verbs (like %v, %d, %s)
// 2. This returns a function that takes a value of type T
// 3. That function returns a ReaderIO that performs the logging when executed
//
// # Parameters
//
// - prefix: A format string compatible with fmt.Printf (e.g., "Value: %v", "Count: %d")
// The format string should contain exactly one format verb that matches type T
//
// # Returns
//
// - A function that takes a value of type T and returns a [ReaderIO][*testing.T, Void]
// When executed, this ReaderIO logs the formatted message to the test output
//
// # Type Parameters
//
// - T: The type of value to be logged. Can be any type that can be formatted by fmt
//
// # Use Cases
//
// - Debugging test execution by logging intermediate values
// - Tracing the flow of complex test scenarios
// - Documenting test behavior in the test output
// - Logging values in functional pipelines without breaking the chain
// - Creating reusable logging operations for specific types
//
// # Example - Basic Logging
//
// func TestBasicLogging(t *testing.T) {
// // Create a logger for integers
// logInt := assert.Logf[int]("Processing value: %d")
//
// // Use it to log a value
// value := 42
// logInt(value)(t)() // Outputs: "Processing value: 42"
// }
//
// # Example - Logging in Test Pipeline
//
// func TestPipelineWithLogging(t *testing.T) {
// type User struct {
// Name string
// Age int
// }
//
// user := User{Name: "Alice", Age: 30}
//
// // Create a logger for User
// logUser := assert.Logf[User]("Testing user: %+v")
//
// // Log the user being tested
// logUser(user)(t)()
//
// // Continue with assertions
// assert.StringNotEmpty(user.Name)(t)
// assert.That(func(age int) bool { return age > 0 })(user.Age)(t)
// }
//
// # Example - Multiple Loggers for Different Types
//
// func TestMultipleLoggers(t *testing.T) {
// // Create type-specific loggers
// logString := assert.Logf[string]("String value: %s")
// logInt := assert.Logf[int]("Integer value: %d")
// logFloat := assert.Logf[float64]("Float value: %.2f")
//
// // Use them throughout the test
// logString("hello")(t)() // Outputs: "String value: hello"
// logInt(42)(t)() // Outputs: "Integer value: 42"
// logFloat(3.14159)(t)() // Outputs: "Float value: 3.14"
// }
//
// # Example - Logging Complex Structures
//
// func TestComplexStructureLogging(t *testing.T) {
// type Config struct {
// Host string
// Port int
// Timeout int
// }
//
// config := Config{Host: "localhost", Port: 8080, Timeout: 30}
//
// // Use %+v to include field names
// logConfig := assert.Logf[Config]("Configuration: %+v")
// logConfig(config)(t)()
// // Outputs: "Configuration: {Host:localhost Port:8080 Timeout:30}"
//
// // Or use %#v for Go-syntax representation
// logConfigGo := assert.Logf[Config]("Config (Go syntax): %#v")
// logConfigGo(config)(t)()
// // Outputs: "Config (Go syntax): assert.Config{Host:"localhost", Port:8080, Timeout:30}"
// }
//
// # Example - Debugging Test Failures
//
// func TestWithDebugLogging(t *testing.T) {
// numbers := []int{1, 2, 3, 4, 5}
// logSlice := assert.Logf[[]int]("Testing slice: %v")
//
// // Log the input data
// logSlice(numbers)(t)()
//
// // Perform assertions
// assert.ArrayNotEmpty(numbers)(t)
// assert.ArrayLength[int](5)(numbers)(t)
//
// // Log intermediate results
// sum := 0
// for _, n := range numbers {
// sum += n
// }
// logInt := assert.Logf[int]("Sum: %d")
// logInt(sum)(t)()
//
// assert.Equal(15)(sum)(t)
// }
//
// # Example - Conditional Logging
//
// func TestConditionalLogging(t *testing.T) {
// logDebug := assert.Logf[string]("DEBUG: %s")
//
// values := []int{1, 2, 3, 4, 5}
// for _, v := range values {
// if v%2 == 0 {
// logDebug(fmt.Sprintf("Found even number: %d", v))(t)()
// }
// }
// // Outputs:
// // DEBUG: Found even number: 2
// // DEBUG: Found even number: 4
// }
//
// # Format Verbs
//
// Common format verbs you can use in the prefix string:
// - %v: Default format
// - %+v: Default format with field names for structs
// - %#v: Go-syntax representation
// - %T: Type of the value
// - %d: Integer in base 10
// - %s: String
// - %f: Floating point number
// - %t: Boolean (true/false)
// - %p: Pointer address
//
// See the fmt package documentation for a complete list of format verbs.
//
// # Notes
//
// - Logging does not affect test pass/fail status
// - Log output appears in test results when running with -v flag or when tests fail
// - The function returns Void, indicating it's used for side effects only
// - The ReaderIO pattern allows logging to be composed with other operations
//
// # Related Functions
//
// - [FromReaderIO]: Converts ReaderIO operations into test assertions
// - testing.T.Logf: The underlying Go testing log function
//
// # References
//
// - Go testing package: https://pkg.go.dev/testing
// - fmt package format verbs: https://pkg.go.dev/fmt
// - ReaderIO pattern: Combines Reader (context dependency) with IO (side effects)
func Logf[T any](prefix string) func(T) readerio.ReaderIO[*testing.T, Void] {
return func(a T) readerio.ReaderIO[*testing.T, Void] {
return func(t *testing.T) IO[Void] {
return io.FromImpure(func() {
t.Logf(prefix, a)
})
}
}
}

406
v2/assert/logger_test.go Normal file
View File

@@ -0,0 +1,406 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"fmt"
"testing"
)
// TestLogf_BasicInteger tests basic integer logging
func TestLogf_BasicInteger(t *testing.T) {
logInt := Logf[int]("Processing value: %d")
// This should not panic and should log the value
logInt(42)(t)()
// Test passes if no panic occurs
}
// TestLogf_BasicString tests basic string logging
func TestLogf_BasicString(t *testing.T) {
logString := Logf[string]("String value: %s")
logString("hello world")(t)()
// Test passes if no panic occurs
}
// TestLogf_BasicFloat tests basic float logging
func TestLogf_BasicFloat(t *testing.T) {
logFloat := Logf[float64]("Float value: %.2f")
logFloat(3.14159)(t)()
// Test passes if no panic occurs
}
// TestLogf_BasicBoolean tests basic boolean logging
func TestLogf_BasicBoolean(t *testing.T) {
logBool := Logf[bool]("Boolean value: %t")
logBool(true)(t)()
logBool(false)(t)()
// Test passes if no panic occurs
}
// TestLogf_ComplexStruct tests logging of complex structures
func TestLogf_ComplexStruct(t *testing.T) {
type User struct {
Name string
Age int
}
logUser := Logf[User]("User: %+v")
user := User{Name: "Alice", Age: 30}
logUser(user)(t)()
// Test passes if no panic occurs
}
// TestLogf_Slice tests logging of slices
func TestLogf_Slice(t *testing.T) {
logSlice := Logf[[]int]("Slice: %v")
numbers := []int{1, 2, 3, 4, 5}
logSlice(numbers)(t)()
// Test passes if no panic occurs
}
// TestLogf_Map tests logging of maps
func TestLogf_Map(t *testing.T) {
logMap := Logf[map[string]int]("Map: %v")
data := map[string]int{"a": 1, "b": 2, "c": 3}
logMap(data)(t)()
// Test passes if no panic occurs
}
// TestLogf_Pointer tests logging of pointers
func TestLogf_Pointer(t *testing.T) {
logPtr := Logf[*int]("Pointer: %p")
value := 42
logPtr(&value)(t)()
// Test passes if no panic occurs
}
// TestLogf_NilPointer tests logging of nil pointers
func TestLogf_NilPointer(t *testing.T) {
logPtr := Logf[*int]("Pointer: %v")
var nilPtr *int
logPtr(nilPtr)(t)()
// Test passes if no panic occurs
}
// TestLogf_EmptyString tests logging of empty strings
func TestLogf_EmptyString(t *testing.T) {
logString := Logf[string]("String: '%s'")
logString("")(t)()
// Test passes if no panic occurs
}
// TestLogf_EmptySlice tests logging of empty slices
func TestLogf_EmptySlice(t *testing.T) {
logSlice := Logf[[]int]("Slice: %v")
logSlice([]int{})(t)()
// Test passes if no panic occurs
}
// TestLogf_EmptyMap tests logging of empty maps
func TestLogf_EmptyMap(t *testing.T) {
logMap := Logf[map[string]int]("Map: %v")
logMap(map[string]int{})(t)()
// Test passes if no panic occurs
}
// TestLogf_MultipleTypes tests using multiple loggers for different types
func TestLogf_MultipleTypes(t *testing.T) {
logString := Logf[string]("String: %s")
logInt := Logf[int]("Integer: %d")
logFloat := Logf[float64]("Float: %.2f")
logString("test")(t)()
logInt(42)(t)()
logFloat(3.14)(t)()
// Test passes if no panic occurs
}
// TestLogf_WithinTestPipeline tests logging within a test pipeline
func TestLogf_WithinTestPipeline(t *testing.T) {
type Config struct {
Host string
Port int
}
config := Config{Host: "localhost", Port: 8080}
logConfig := Logf[Config]("Testing config: %+v")
logConfig(config)(t)()
// Continue with assertions
StringNotEmpty(config.Host)(t)
That(func(port int) bool { return port > 0 })(config.Port)(t)
// Test passes if no panic occurs and assertions pass
}
// TestLogf_NestedStructures tests logging of nested structures
func TestLogf_NestedStructures(t *testing.T) {
type Address struct {
Street string
City string
}
type Person struct {
Name string
Address Address
}
logPerson := Logf[Person]("Person: %+v")
person := Person{
Name: "Bob",
Address: Address{
Street: "123 Main St",
City: "Springfield",
},
}
logPerson(person)(t)()
// Test passes if no panic occurs
}
// TestLogf_Interface tests logging of interface values
func TestLogf_Interface(t *testing.T) {
logAny := Logf[any]("Value: %v")
logAny(42)(t)()
logAny("string")(t)()
logAny([]int{1, 2, 3})(t)()
// Test passes if no panic occurs
}
// TestLogf_GoSyntaxFormat tests logging with Go-syntax format
func TestLogf_GoSyntaxFormat(t *testing.T) {
type Point struct {
X int
Y int
}
logPoint := Logf[Point]("Point: %#v")
point := Point{X: 10, Y: 20}
logPoint(point)(t)()
// Test passes if no panic occurs
}
// TestLogf_TypeFormat tests logging with type format
func TestLogf_TypeFormat(t *testing.T) {
logType := Logf[any]("Type: %T, Value: %v")
logType(42)(t)()
logType("string")(t)()
logType(3.14)(t)()
// Test passes if no panic occurs
}
// TestLogf_LargeNumbers tests logging of large numbers
func TestLogf_LargeNumbers(t *testing.T) {
logInt := Logf[int64]("Large number: %d")
logInt(9223372036854775807)(t)() // Max int64
// Test passes if no panic occurs
}
// TestLogf_NegativeNumbers tests logging of negative numbers
func TestLogf_NegativeNumbers(t *testing.T) {
logInt := Logf[int]("Number: %d")
logInt(-42)(t)()
logInt(-100)(t)()
// Test passes if no panic occurs
}
// TestLogf_SpecialFloats tests logging of special float values
func TestLogf_SpecialFloats(t *testing.T) {
logFloat := Logf[float64]("Float: %v")
logFloat(0.0)(t)()
logFloat(-0.0)(t)()
// Test passes if no panic occurs
}
// TestLogf_UnicodeStrings tests logging of unicode strings
func TestLogf_UnicodeStrings(t *testing.T) {
logString := Logf[string]("Unicode: %s")
logString("Hello, 世界")(t)()
logString("Emoji: 🎉🎊")(t)()
// Test passes if no panic occurs
}
// TestLogf_MultilineStrings tests logging of multiline strings
func TestLogf_MultilineStrings(t *testing.T) {
logString := Logf[string]("Multiline:\n%s")
multiline := `Line 1
Line 2
Line 3`
logString(multiline)(t)()
// Test passes if no panic occurs
}
// TestLogf_ReuseLogger tests reusing the same logger multiple times
func TestLogf_ReuseLogger(t *testing.T) {
logInt := Logf[int]("Value: %d")
for i := 0; i < 5; i++ {
logInt(i)(t)()
}
// Test passes if no panic occurs
}
// TestLogf_ConditionalLogging tests conditional logging based on values
func TestLogf_ConditionalLogging(t *testing.T) {
logDebug := Logf[string]("DEBUG: %s")
values := []int{1, 2, 3, 4, 5}
for _, v := range values {
if v%2 == 0 {
logDebug(fmt.Sprintf("Found even number: %d", v))(t)()
}
}
// Test passes if no panic occurs
}
// TestLogf_WithAssertions tests combining logging with assertions
func TestLogf_WithAssertions(t *testing.T) {
logInt := Logf[int]("Testing value: %d")
value := 42
logInt(value)(t)()
// Perform assertion after logging
Equal(42)(value)(t)
// Test passes if assertion passes
}
// TestLogf_DebuggingFailures demonstrates using logging to debug test failures
func TestLogf_DebuggingFailures(t *testing.T) {
logSlice := Logf[[]int]("Input slice: %v")
logInt := Logf[int]("Computed sum: %d")
numbers := []int{1, 2, 3, 4, 5}
logSlice(numbers)(t)()
sum := 0
for _, n := range numbers {
sum += n
}
logInt(sum)(t)()
Equal(15)(sum)(t)
// Test passes if assertion passes
}
// TestLogf_ComplexDataStructures tests logging of complex nested data
func TestLogf_ComplexDataStructures(t *testing.T) {
type Metadata struct {
Version string
Tags []string
}
type Document struct {
ID int
Title string
Metadata Metadata
}
logDoc := Logf[Document]("Document: %+v")
doc := Document{
ID: 1,
Title: "Test Document",
Metadata: Metadata{
Version: "1.0",
Tags: []string{"test", "example"},
},
}
logDoc(doc)(t)()
// Test passes if no panic occurs
}
// TestLogf_ArrayTypes tests logging of array types
func TestLogf_ArrayTypes(t *testing.T) {
logArray := Logf[[5]int]("Array: %v")
arr := [5]int{1, 2, 3, 4, 5}
logArray(arr)(t)()
// Test passes if no panic occurs
}
// TestLogf_ChannelTypes tests logging of channel types
func TestLogf_ChannelTypes(t *testing.T) {
logChan := Logf[chan int]("Channel: %v")
ch := make(chan int, 1)
logChan(ch)(t)()
close(ch)
// Test passes if no panic occurs
}
// TestLogf_FunctionTypes tests logging of function types
func TestLogf_FunctionTypes(t *testing.T) {
logFunc := Logf[func() int]("Function: %v")
fn := func() int { return 42 }
logFunc(fn)(t)()
// Test passes if no panic occurs
}

152
v2/assert/monoid.go Normal file
View File

@@ -0,0 +1,152 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"testing"
"github.com/IBM/fp-go/v2/boolean"
"github.com/IBM/fp-go/v2/monoid"
"github.com/IBM/fp-go/v2/reader"
)
// ApplicativeMonoid returns a [monoid.Monoid] for combining test assertion [Reader]s.
//
// This monoid combines multiple test assertions using logical AND (conjunction) semantics,
// meaning all assertions must pass for the combined assertion to pass. It leverages the
// applicative structure of Reader to execute multiple assertions with the same testing.T
// context and combines their boolean results using boolean.MonoidAll (logical AND).
//
// The monoid provides:
// - Concat: Combines two assertions such that both must pass (logical AND)
// - Empty: Returns an assertion that always passes (identity element)
//
// This is particularly useful for:
// - Composing multiple test assertions into a single assertion
// - Building complex test conditions from simpler ones
// - Creating reusable assertion combinators
// - Implementing test assertion DSLs
//
// # Monoid Laws
//
// The returned monoid satisfies the standard monoid laws:
//
// 1. Associativity:
// Concat(Concat(a1, a2), a3) ≡ Concat(a1, Concat(a2, a3))
//
// 2. Left Identity:
// Concat(Empty(), a) ≡ a
//
// 3. Right Identity:
// Concat(a, Empty()) ≡ a
//
// # Returns
//
// - A [monoid.Monoid][Reader] that combines assertions using logical AND
//
// # Example - Basic Usage
//
// func TestUserValidation(t *testing.T) {
// user := User{Name: "Alice", Age: 30, Email: "alice@example.com"}
// m := assert.ApplicativeMonoid()
//
// // Combine multiple assertions
// assertion := m.Concat(
// assert.Equal("Alice")(user.Name),
// m.Concat(
// assert.Equal(30)(user.Age),
// assert.StringNotEmpty(user.Email),
// ),
// )
//
// // Execute combined assertion
// assertion(t) // All three assertions must pass
// }
//
// # Example - Building Reusable Validators
//
// func TestWithReusableValidators(t *testing.T) {
// m := assert.ApplicativeMonoid()
//
// // Create a reusable validator
// validateUser := func(u User) assert.Reader {
// return m.Concat(
// assert.StringNotEmpty(u.Name),
// m.Concat(
// assert.True(u.Age > 0),
// assert.StringContains("@")(u.Email),
// ),
// )
// }
//
// user := User{Name: "Bob", Age: 25, Email: "bob@test.com"}
// validateUser(user)(t)
// }
//
// # Example - Using Empty for Identity
//
// func TestEmptyIdentity(t *testing.T) {
// m := assert.ApplicativeMonoid()
// assertion := assert.Equal(42)(42)
//
// // Empty is the identity - these are equivalent
// result1 := m.Concat(m.Empty(), assertion)(t)
// result2 := m.Concat(assertion, m.Empty())(t)
// result3 := assertion(t)
// // All three produce the same result
// }
//
// # Example - Combining with AllOf
//
// func TestCombiningWithAllOf(t *testing.T) {
// // ApplicativeMonoid provides the underlying mechanism for AllOf
// arr := []int{1, 2, 3, 4, 5}
//
// // These are conceptually equivalent:
// m := assert.ApplicativeMonoid()
// manual := m.Concat(
// assert.ArrayNotEmpty(arr),
// m.Concat(
// assert.ArrayLength[int](5)(arr),
// assert.ArrayContains(3)(arr),
// ),
// )
//
// // AllOf uses ApplicativeMonoid internally
// convenient := assert.AllOf([]assert.Reader{
// assert.ArrayNotEmpty(arr),
// assert.ArrayLength[int](5)(arr),
// assert.ArrayContains(3)(arr),
// })
//
// manual(t)
// convenient(t)
// }
//
// # Related Functions
//
// - [AllOf]: Convenient wrapper for combining multiple assertions using this monoid
// - [boolean.MonoidAll]: The underlying boolean monoid (logical AND with true as identity)
// - [reader.ApplicativeMonoid]: Generic applicative monoid for Reader types
//
// # References
//
// - Haskell Monoid: https://hackage.haskell.org/package/base/docs/Data-Monoid.html
// - Applicative Functors: https://hackage.haskell.org/package/base/docs/Control-Applicative.html
// - Boolean Monoid (All): https://hackage.haskell.org/package/base/docs/Data-Monoid.html#t:All
func ApplicativeMonoid() monoid.Monoid[Reader] {
return reader.ApplicativeMonoid[*testing.T](boolean.MonoidAll)
}

454
v2/assert/monoid_test.go Normal file
View File

@@ -0,0 +1,454 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"testing"
)
// TestApplicativeMonoid_Empty tests that Empty returns an assertion that always passes
func TestApplicativeMonoid_Empty(t *testing.T) {
m := ApplicativeMonoid()
empty := m.Empty()
result := empty(t)
if !result {
t.Error("Expected Empty() to return an assertion that always passes")
}
}
// TestApplicativeMonoid_Concat_BothPass tests that Concat returns true when both assertions pass
func TestApplicativeMonoid_Concat_BothPass(t *testing.T) {
m := ApplicativeMonoid()
assertion1 := Equal(42)(42)
assertion2 := Equal("hello")("hello")
combined := m.Concat(assertion1, assertion2)
result := combined(t)
if !result {
t.Error("Expected Concat to pass when both assertions pass")
}
}
// TestApplicativeMonoid_Concat_FirstFails tests that Concat returns false when first assertion fails
func TestApplicativeMonoid_Concat_FirstFails(t *testing.T) {
mockT := &testing.T{}
m := ApplicativeMonoid()
assertion1 := Equal(42)(43) // This will fail
assertion2 := Equal("hello")("hello")
combined := m.Concat(assertion1, assertion2)
result := combined(mockT)
if result {
t.Error("Expected Concat to fail when first assertion fails")
}
}
// TestApplicativeMonoid_Concat_SecondFails tests that Concat returns false when second assertion fails
func TestApplicativeMonoid_Concat_SecondFails(t *testing.T) {
mockT := &testing.T{}
m := ApplicativeMonoid()
assertion1 := Equal(42)(42)
assertion2 := Equal("hello")("world") // This will fail
combined := m.Concat(assertion1, assertion2)
result := combined(mockT)
if result {
t.Error("Expected Concat to fail when second assertion fails")
}
}
// TestApplicativeMonoid_Concat_BothFail tests that Concat returns false when both assertions fail
func TestApplicativeMonoid_Concat_BothFail(t *testing.T) {
mockT := &testing.T{}
m := ApplicativeMonoid()
assertion1 := Equal(42)(43) // This will fail
assertion2 := Equal("hello")("world") // This will fail
combined := m.Concat(assertion1, assertion2)
result := combined(mockT)
if result {
t.Error("Expected Concat to fail when both assertions fail")
}
}
// TestApplicativeMonoid_LeftIdentity tests the left identity law: Concat(Empty(), a) = a
func TestApplicativeMonoid_LeftIdentity(t *testing.T) {
m := ApplicativeMonoid()
assertion := Equal(42)(42)
// Concat(Empty(), assertion) should behave the same as assertion
combined := m.Concat(m.Empty(), assertion)
result1 := assertion(t)
result2 := combined(t)
if result1 != result2 {
t.Error("Left identity law violated: Concat(Empty(), a) should equal a")
}
}
// TestApplicativeMonoid_RightIdentity tests the right identity law: Concat(a, Empty()) = a
func TestApplicativeMonoid_RightIdentity(t *testing.T) {
m := ApplicativeMonoid()
assertion := Equal(42)(42)
// Concat(assertion, Empty()) should behave the same as assertion
combined := m.Concat(assertion, m.Empty())
result1 := assertion(t)
result2 := combined(t)
if result1 != result2 {
t.Error("Right identity law violated: Concat(a, Empty()) should equal a")
}
}
// TestApplicativeMonoid_Associativity tests the associativity law: Concat(Concat(a, b), c) = Concat(a, Concat(b, c))
func TestApplicativeMonoid_Associativity(t *testing.T) {
m := ApplicativeMonoid()
a1 := Equal(1)(1)
a2 := Equal(2)(2)
a3 := Equal(3)(3)
// Concat(Concat(a1, a2), a3)
left := m.Concat(m.Concat(a1, a2), a3)
// Concat(a1, Concat(a2, a3))
right := m.Concat(a1, m.Concat(a2, a3))
result1 := left(t)
result2 := right(t)
if result1 != result2 {
t.Error("Associativity law violated: Concat(Concat(a, b), c) should equal Concat(a, Concat(b, c))")
}
}
// TestApplicativeMonoid_AssociativityWithFailure tests associativity when assertions fail
func TestApplicativeMonoid_AssociativityWithFailure(t *testing.T) {
mockT := &testing.T{}
m := ApplicativeMonoid()
a1 := Equal(1)(1)
a2 := Equal(2)(3) // This will fail
a3 := Equal(3)(3)
// Concat(Concat(a1, a2), a3)
left := m.Concat(m.Concat(a1, a2), a3)
// Concat(a1, Concat(a2, a3))
right := m.Concat(a1, m.Concat(a2, a3))
result1 := left(mockT)
result2 := right(mockT)
if result1 != result2 {
t.Error("Associativity law violated even with failures")
}
if result1 || result2 {
t.Error("Expected both to fail when one assertion fails")
}
}
// TestApplicativeMonoid_ComplexAssertions tests combining complex assertions
func TestApplicativeMonoid_ComplexAssertions(t *testing.T) {
m := ApplicativeMonoid()
arr := []int{1, 2, 3, 4, 5}
mp := map[string]int{"a": 1, "b": 2}
arrayAssertions := m.Concat(
ArrayNotEmpty(arr),
m.Concat(
ArrayLength[int](5)(arr),
ArrayContains(3)(arr),
),
)
mapAssertions := m.Concat(
RecordNotEmpty(mp),
RecordLength[string, int](2)(mp),
)
combined := m.Concat(arrayAssertions, mapAssertions)
result := combined(t)
if !result {
t.Error("Expected complex combined assertions to pass")
}
}
// TestApplicativeMonoid_ComplexAssertionsWithFailure tests complex assertions when one fails
func TestApplicativeMonoid_ComplexAssertionsWithFailure(t *testing.T) {
mockT := &testing.T{}
m := ApplicativeMonoid()
arr := []int{1, 2, 3}
mp := map[string]int{"a": 1, "b": 2}
arrayAssertions := m.Concat(
ArrayNotEmpty(arr),
m.Concat(
ArrayLength[int](5)(arr), // This will fail - array has 3 elements, not 5
ArrayContains(3)(arr),
),
)
mapAssertions := m.Concat(
RecordNotEmpty(mp),
RecordLength[string, int](2)(mp),
)
combined := m.Concat(arrayAssertions, mapAssertions)
result := combined(mockT)
if result {
t.Error("Expected complex combined assertions to fail when one assertion fails")
}
}
// TestApplicativeMonoid_MultipleConcat tests chaining multiple Concat operations
func TestApplicativeMonoid_MultipleConcat(t *testing.T) {
m := ApplicativeMonoid()
a1 := Equal(1)(1)
a2 := Equal(2)(2)
a3 := Equal(3)(3)
a4 := Equal(4)(4)
combined := m.Concat(
m.Concat(a1, a2),
m.Concat(a3, a4),
)
result := combined(t)
if !result {
t.Error("Expected multiple Concat operations to pass when all assertions pass")
}
}
// TestApplicativeMonoid_WithStringAssertions tests combining string assertions
func TestApplicativeMonoid_WithStringAssertions(t *testing.T) {
m := ApplicativeMonoid()
str := "hello world"
combined := m.Concat(
StringNotEmpty(str),
StringLength[any, any](11)(str),
)
result := combined(t)
if !result {
t.Error("Expected string assertions to pass")
}
}
// TestApplicativeMonoid_WithBooleanAssertions tests combining boolean assertions
func TestApplicativeMonoid_WithBooleanAssertions(t *testing.T) {
m := ApplicativeMonoid()
combined := m.Concat(
Equal(true)(true),
m.Concat(
Equal(false)(false),
Equal(true)(true),
),
)
result := combined(t)
if !result {
t.Error("Expected boolean assertions to pass")
}
}
// TestApplicativeMonoid_WithErrorAssertions tests combining error assertions
func TestApplicativeMonoid_WithErrorAssertions(t *testing.T) {
m := ApplicativeMonoid()
combined := m.Concat(
NoError(nil),
Equal("test")("test"),
)
result := combined(t)
if !result {
t.Error("Expected error assertions to pass")
}
}
// TestApplicativeMonoid_EmptyWithMultipleConcat tests Empty with multiple Concat operations
func TestApplicativeMonoid_EmptyWithMultipleConcat(t *testing.T) {
m := ApplicativeMonoid()
assertion := Equal(42)(42)
// Multiple Empty values should still act as identity
combined := m.Concat(
m.Empty(),
m.Concat(
assertion,
m.Empty(),
),
)
result1 := assertion(t)
result2 := combined(t)
if result1 != result2 {
t.Error("Multiple Empty values should still act as identity")
}
}
// TestApplicativeMonoid_OnlyEmpty tests using only Empty values
func TestApplicativeMonoid_OnlyEmpty(t *testing.T) {
m := ApplicativeMonoid()
// Concat of Empty values should still be Empty (identity)
combined := m.Concat(m.Empty(), m.Empty())
result := combined(t)
if !result {
t.Error("Expected Concat of Empty values to pass")
}
}
// TestApplicativeMonoid_RealWorldExample tests a realistic use case
func TestApplicativeMonoid_RealWorldExample(t *testing.T) {
type User struct {
Name string
Age int
Email string
}
m := ApplicativeMonoid()
validateUser := func(u User) Reader {
return m.Concat(
StringNotEmpty(u.Name),
m.Concat(
That(func(age int) bool { return age > 0 })(u.Age),
m.Concat(
That(func(age int) bool { return age < 150 })(u.Age),
That(func(email string) bool {
for _, ch := range email {
if ch == '@' {
return true
}
}
return false
})(u.Email),
),
),
)
}
validUser := User{Name: "Alice", Age: 30, Email: "alice@example.com"}
result := validateUser(validUser)(t)
if !result {
t.Error("Expected valid user to pass all validations")
}
}
// TestApplicativeMonoid_RealWorldExampleWithFailure tests a realistic use case with failure
func TestApplicativeMonoid_RealWorldExampleWithFailure(t *testing.T) {
mockT := &testing.T{}
type User struct {
Name string
Age int
Email string
}
m := ApplicativeMonoid()
validateUser := func(u User) Reader {
return m.Concat(
StringNotEmpty(u.Name),
m.Concat(
That(func(age int) bool { return age > 0 })(u.Age),
m.Concat(
That(func(age int) bool { return age < 150 })(u.Age),
That(func(email string) bool {
for _, ch := range email {
if ch == '@' {
return true
}
}
return false
})(u.Email),
),
),
)
}
invalidUser := User{Name: "Bob", Age: 200, Email: "bob@test.com"} // Age > 150
result := validateUser(invalidUser)(mockT)
if result {
t.Error("Expected invalid user to fail validation")
}
}
// TestApplicativeMonoid_IntegrationWithAllOf demonstrates relationship with AllOf
func TestApplicativeMonoid_IntegrationWithAllOf(t *testing.T) {
m := ApplicativeMonoid()
arr := []int{1, 2, 3, 4, 5}
// Using ApplicativeMonoid directly
manualCombination := m.Concat(
ArrayNotEmpty(arr),
m.Concat(
ArrayLength[int](5)(arr),
ArrayContains(3)(arr),
),
)
// Using AllOf (which uses ApplicativeMonoid internally)
allOfCombination := AllOf([]Reader{
ArrayNotEmpty(arr),
ArrayLength[int](5)(arr),
ArrayContains(3)(arr),
})
result1 := manualCombination(t)
result2 := allOfCombination(t)
if result1 != result2 {
t.Error("Expected manual combination and AllOf to produce same result")
}
if !result1 || !result2 {
t.Error("Expected both combinations to pass")
}
}

650
v2/assert/traverse.go Normal file
View File

@@ -0,0 +1,650 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"testing"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
)
// TraverseArray transforms an array of values into a test suite by applying a function
// that generates named test cases for each element.
//
// This function enables data-driven testing where you have a collection of test inputs
// and want to run a named subtest for each one. It follows the functional programming
// pattern of "traverse" - transforming a collection while preserving structure and
// accumulating effects (in this case, test execution).
//
// The function takes each element of the array, applies the provided function to generate
// a [Pair] of (test name, test assertion), and runs each as a separate subtest using
// Go's t.Run. All subtests must pass for the overall test to pass.
//
// # Parameters
//
// - f: A function that takes a value of type T and returns a [Pair] containing:
// - Head: The test name (string) for the subtest
// - Tail: The test assertion ([Reader]) to execute
//
// # Returns
//
// - A [Kleisli] function that takes an array of T and returns a [Reader] that:
// - Executes each element as a named subtest
// - Returns true only if all subtests pass
// - Provides proper test isolation and reporting via t.Run
//
// # Use Cases
//
// - Data-driven testing with multiple test cases
// - Parameterized tests where each parameter gets its own subtest
// - Testing collections where each element needs validation
// - Property-based testing with generated test data
//
// # Example - Basic Data-Driven Testing
//
// func TestMathOperations(t *testing.T) {
// type TestCase struct {
// Input int
// Expected int
// }
//
// testCases := []TestCase{
// {Input: 2, Expected: 4},
// {Input: 3, Expected: 9},
// {Input: 4, Expected: 16},
// }
//
// square := func(n int) int { return n * n }
//
// traverse := assert.TraverseArray(func(tc TestCase) assert.Pair[string, assert.Reader] {
// name := fmt.Sprintf("square(%d)=%d", tc.Input, tc.Expected)
// assertion := assert.Equal(tc.Expected)(square(tc.Input))
// return pair.MakePair(name, assertion)
// })
//
// traverse(testCases)(t)
// }
//
// # Example - String Validation
//
// func TestStringValidation(t *testing.T) {
// inputs := []string{"hello", "world", "test"}
//
// traverse := assert.TraverseArray(func(s string) assert.Pair[string, assert.Reader] {
// return pair.MakePair(
// fmt.Sprintf("validate_%s", s),
// assert.AllOf([]assert.Reader{
// assert.StringNotEmpty(s),
// assert.That(func(str string) bool { return len(str) > 0 })(s),
// }),
// )
// })
//
// traverse(inputs)(t)
// }
//
// # Example - Complex Object Testing
//
// func TestUsers(t *testing.T) {
// type User struct {
// Name string
// Age int
// Email string
// }
//
// users := []User{
// {Name: "Alice", Age: 30, Email: "alice@example.com"},
// {Name: "Bob", Age: 25, Email: "bob@example.com"},
// }
//
// traverse := assert.TraverseArray(func(u User) assert.Pair[string, assert.Reader] {
// return pair.MakePair(
// fmt.Sprintf("user_%s", u.Name),
// assert.AllOf([]assert.Reader{
// assert.StringNotEmpty(u.Name),
// assert.That(func(age int) bool { return age > 0 })(u.Age),
// assert.That(func(email string) bool {
// return len(email) > 0 && strings.Contains(email, "@")
// })(u.Email),
// }),
// )
// })
//
// traverse(users)(t)
// }
//
// # Comparison with RunAll
//
// TraverseArray and [RunAll] serve similar purposes but differ in their approach:
//
// - TraverseArray: Generates test cases from an array of data
//
// - Input: Array of values + function to generate test cases
//
// - Use when: You have test data and need to generate test cases from it
//
// - RunAll: Executes pre-defined named test cases
//
// - Input: Map of test names to assertions
//
// - Use when: You have already defined test cases with names
//
// # Related Functions
//
// - [SequenceSeq2]: Similar but works with Go iterators (Seq2) instead of arrays
// - [RunAll]: Executes a map of named test cases
// - [AllOf]: Combines multiple assertions without subtests
//
// # References
//
// - Haskell traverse: https://hackage.haskell.org/package/base/docs/Data-Traversable.html#v:traverse
// - Go subtests: https://go.dev/blog/subtests
func TraverseArray[T any](f func(T) Pair[string, Reader]) Kleisli[[]T] {
return func(ts []T) Reader {
return func(t *testing.T) bool {
ok := true
for _, src := range ts {
test := f(src)
res := t.Run(pair.Head(test), func(t *testing.T) {
pair.Tail(test)(t)
})
ok = ok && res
}
return ok
}
}
}
// SequenceSeq2 executes a sequence of named test cases provided as a Go iterator.
//
// This function takes a [Seq2] iterator that yields (name, assertion) pairs and
// executes each as a separate subtest using Go's t.Run. It's similar to [TraverseArray]
// but works directly with Go's iterator protocol (introduced in Go 1.23) rather than
// requiring an array.
//
// The function iterates through all test cases, running each as a named subtest.
// All subtests must pass for the overall test to pass. This provides proper test
// isolation and clear reporting of which specific test cases fail.
//
// # Parameters
//
// - s: A [Seq2] iterator that yields pairs of:
// - Key: Test name (string) for the subtest
// - Value: Test assertion ([Reader]) to execute
//
// # Returns
//
// - A [Reader] that:
// - Executes each test case as a named subtest
// - Returns true only if all subtests pass
// - Provides proper test isolation via t.Run
//
// # Use Cases
//
// - Working with iterator-based test data
// - Lazy evaluation of test cases
// - Integration with Go 1.23+ iterator patterns
// - Memory-efficient testing of large test suites
//
// # Example - Basic Usage with Iterator
//
// func TestWithIterator(t *testing.T) {
// // Create an iterator of test cases
// testCases := func(yield func(string, assert.Reader) bool) {
// if !yield("test_addition", assert.Equal(4)(2+2)) {
// return
// }
// if !yield("test_subtraction", assert.Equal(1)(3-2)) {
// return
// }
// if !yield("test_multiplication", assert.Equal(6)(2*3)) {
// return
// }
// }
//
// assert.SequenceSeq2(testCases)(t)
// }
//
// # Example - Generated Test Cases
//
// func TestGeneratedCases(t *testing.T) {
// // Generate test cases on the fly
// generateTests := func(yield func(string, assert.Reader) bool) {
// for i := 1; i <= 5; i++ {
// name := fmt.Sprintf("test_%d", i)
// assertion := assert.Equal(i*i)(i * i)
// if !yield(name, assertion) {
// return
// }
// }
// }
//
// assert.SequenceSeq2(generateTests)(t)
// }
//
// # Example - Filtering Test Cases
//
// func TestFilteredCases(t *testing.T) {
// type TestCase struct {
// Name string
// Input int
// Expected int
// Skip bool
// }
//
// allCases := []TestCase{
// {Name: "test1", Input: 2, Expected: 4, Skip: false},
// {Name: "test2", Input: 3, Expected: 9, Skip: true},
// {Name: "test3", Input: 4, Expected: 16, Skip: false},
// }
//
// // Create iterator that filters out skipped tests
// activeTests := func(yield func(string, assert.Reader) bool) {
// for _, tc := range allCases {
// if !tc.Skip {
// assertion := assert.Equal(tc.Expected)(tc.Input * tc.Input)
// if !yield(tc.Name, assertion) {
// return
// }
// }
// }
// }
//
// assert.SequenceSeq2(activeTests)(t)
// }
//
// # Comparison with TraverseArray
//
// SequenceSeq2 and [TraverseArray] serve similar purposes but differ in their input:
//
// - SequenceSeq2: Works with iterators (Seq2)
//
// - Input: Iterator yielding (name, assertion) pairs
//
// - Use when: Working with Go 1.23+ iterators or lazy evaluation
//
// - Memory: More efficient for large test suites (lazy evaluation)
//
// - TraverseArray: Works with arrays
//
// - Input: Array of values + transformation function
//
// - Use when: You have an array of test data
//
// - Memory: All test data must be in memory
//
// # Comparison with RunAll
//
// SequenceSeq2 and [RunAll] are very similar:
//
// - SequenceSeq2: Takes an iterator (Seq2)
// - RunAll: Takes a map[string]Reader
//
// Both execute named test cases as subtests. Choose based on your data structure:
// use SequenceSeq2 for iterators, RunAll for maps.
//
// # Related Functions
//
// - [TraverseArray]: Similar but works with arrays instead of iterators
// - [RunAll]: Executes a map of named test cases
// - [AllOf]: Combines multiple assertions without subtests
//
// # References
//
// - Go iterators: https://go.dev/blog/range-functions
// - Go subtests: https://go.dev/blog/subtests
// - Haskell sequence: https://hackage.haskell.org/package/base/docs/Data-Traversable.html#v:sequence
func SequenceSeq2[T any](s Seq2[string, Reader]) Reader {
return func(t *testing.T) bool {
ok := true
for name, test := range s {
res := t.Run(name, func(t *testing.T) {
test(t)
})
ok = ok && res
}
return ok
}
}
// TraverseRecord transforms a map of values into a test suite by applying a function
// that generates test assertions for each map entry.
//
// This function enables data-driven testing where you have a map of test data and want
// to run a named subtest for each entry. The map keys become test names, and the function
// transforms each value into a test assertion. It follows the functional programming
// pattern of "traverse" - transforming a collection while preserving structure and
// accumulating effects (in this case, test execution).
//
// The function takes each key-value pair from the map, applies the provided function to
// generate a [Reader] assertion, and runs each as a separate subtest using Go's t.Run.
// All subtests must pass for the overall test to pass.
//
// # Parameters
//
// - f: A [Kleisli] function that takes a value of type T and returns a [Reader] assertion
//
// # Returns
//
// - A [Kleisli] function that takes a map[string]T and returns a [Reader] that:
// - Executes each map entry as a named subtest (using the key as the test name)
// - Returns true only if all subtests pass
// - Provides proper test isolation and reporting via t.Run
//
// # Use Cases
//
// - Data-driven testing with named test cases in a map
// - Testing configuration maps where keys are meaningful names
// - Validating collections where natural keys exist
// - Property-based testing with named scenarios
//
// # Example - Basic Configuration Testing
//
// func TestConfigurations(t *testing.T) {
// configs := map[string]int{
// "timeout": 30,
// "maxRetries": 3,
// "bufferSize": 1024,
// }
//
// validatePositive := assert.That(func(n int) bool { return n > 0 })
//
// traverse := assert.TraverseRecord(validatePositive)
// traverse(configs)(t)
// }
//
// # Example - User Validation
//
// func TestUserMap(t *testing.T) {
// type User struct {
// Name string
// Age int
// }
//
// users := map[string]User{
// "alice": {Name: "Alice", Age: 30},
// "bob": {Name: "Bob", Age: 25},
// "carol": {Name: "Carol", Age: 35},
// }
//
// validateUser := func(u User) assert.Reader {
// return assert.AllOf([]assert.Reader{
// assert.StringNotEmpty(u.Name),
// assert.That(func(age int) bool { return age > 0 && age < 150 })(u.Age),
// })
// }
//
// traverse := assert.TraverseRecord(validateUser)
// traverse(users)(t)
// }
//
// # Example - API Endpoint Testing
//
// func TestEndpoints(t *testing.T) {
// type Endpoint struct {
// Path string
// Method string
// }
//
// endpoints := map[string]Endpoint{
// "get_users": {Path: "/api/users", Method: "GET"},
// "create_user": {Path: "/api/users", Method: "POST"},
// "delete_user": {Path: "/api/users/:id", Method: "DELETE"},
// }
//
// validateEndpoint := func(e Endpoint) assert.Reader {
// return assert.AllOf([]assert.Reader{
// assert.StringNotEmpty(e.Path),
// assert.That(func(path string) bool {
// return strings.HasPrefix(path, "/api/")
// })(e.Path),
// assert.That(func(method string) bool {
// return method == "GET" || method == "POST" ||
// method == "PUT" || method == "DELETE"
// })(e.Method),
// })
// }
//
// traverse := assert.TraverseRecord(validateEndpoint)
// traverse(endpoints)(t)
// }
//
// # Comparison with TraverseArray
//
// TraverseRecord and [TraverseArray] serve similar purposes but differ in their input:
//
// - TraverseRecord: Works with maps (records)
//
// - Input: Map with string keys + transformation function
//
// - Use when: You have named test data in a map
//
// - Test names: Derived from map keys
//
// - TraverseArray: Works with arrays
//
// - Input: Array of values + function that generates names and assertions
//
// - Use when: You have sequential test data
//
// - Test names: Generated by the transformation function
//
// # Comparison with SequenceRecord
//
// TraverseRecord and [SequenceRecord] are closely related:
//
// - TraverseRecord: Transforms values into assertions
//
// - Input: map[string]T + function T -> Reader
//
// - Use when: You need to transform data before asserting
//
// - SequenceRecord: Executes pre-defined assertions
//
// - Input: map[string]Reader
//
// - Use when: Assertions are already defined
//
// # Related Functions
//
// - [SequenceRecord]: Similar but takes pre-defined assertions
// - [TraverseArray]: Similar but works with arrays
// - [RunAll]: Alias for SequenceRecord
//
// # References
//
// - Haskell traverse: https://hackage.haskell.org/package/base/docs/Data-Traversable.html#v:traverse
// - Go subtests: https://go.dev/blog/subtests
func TraverseRecord[T any](f Kleisli[T]) Kleisli[map[string]T] {
return func(m map[string]T) Reader {
return func(t *testing.T) bool {
ok := true
for name, src := range m {
res := t.Run(name, func(t *testing.T) {
f(src)(t)
})
ok = ok && res
}
return ok
}
}
}
// SequenceRecord executes a map of named test cases as subtests.
//
// This function takes a map where keys are test names and values are test assertions
// ([Reader]), and executes each as a separate subtest using Go's t.Run. It's the
// record (map) equivalent of [SequenceSeq2] and is actually aliased as [RunAll] for
// convenience.
//
// The function iterates through all map entries, running each as a named subtest.
// All subtests must pass for the overall test to pass. This provides proper test
// isolation and clear reporting of which specific test cases fail.
//
// # Parameters
//
// - m: A map[string]Reader where:
// - Keys: Test names (strings) for the subtests
// - Values: Test assertions ([Reader]) to execute
//
// # Returns
//
// - A [Reader] that:
// - Executes each map entry as a named subtest
// - Returns true only if all subtests pass
// - Provides proper test isolation via t.Run
//
// # Use Cases
//
// - Executing a collection of pre-defined named test cases
// - Organizing related tests in a map structure
// - Running multiple assertions with descriptive names
// - Building test suites programmatically
//
// # Example - Basic Named Tests
//
// func TestMathOperations(t *testing.T) {
// tests := map[string]assert.Reader{
// "addition": assert.Equal(4)(2 + 2),
// "subtraction": assert.Equal(1)(3 - 2),
// "multiplication": assert.Equal(6)(2 * 3),
// "division": assert.Equal(2)(6 / 3),
// }
//
// assert.SequenceRecord(tests)(t)
// }
//
// # Example - String Validation Suite
//
// func TestStringValidations(t *testing.T) {
// testString := "hello world"
//
// tests := map[string]assert.Reader{
// "not_empty": assert.StringNotEmpty(testString),
// "correct_length": assert.StringLength[any, any](11)(testString),
// "has_space": assert.That(func(s string) bool {
// return strings.Contains(s, " ")
// })(testString),
// "lowercase": assert.That(func(s string) bool {
// return s == strings.ToLower(s)
// })(testString),
// }
//
// assert.SequenceRecord(tests)(t)
// }
//
// # Example - Complex Object Validation
//
// func TestUserValidation(t *testing.T) {
// type User struct {
// Name string
// Age int
// Email string
// }
//
// user := User{Name: "Alice", Age: 30, Email: "alice@example.com"}
//
// tests := map[string]assert.Reader{
// "name_not_empty": assert.StringNotEmpty(user.Name),
// "age_positive": assert.That(func(age int) bool { return age > 0 })(user.Age),
// "age_reasonable": assert.That(func(age int) bool { return age < 150 })(user.Age),
// "email_valid": assert.That(func(email string) bool {
// return strings.Contains(email, "@") && strings.Contains(email, ".")
// })(user.Email),
// }
//
// assert.SequenceRecord(tests)(t)
// }
//
// # Example - Array Validation Suite
//
// func TestArrayValidations(t *testing.T) {
// numbers := []int{1, 2, 3, 4, 5}
//
// tests := map[string]assert.Reader{
// "not_empty": assert.ArrayNotEmpty(numbers),
// "correct_length": assert.ArrayLength[int](5)(numbers),
// "contains_three": assert.ArrayContains(3)(numbers),
// "all_positive": assert.That(func(arr []int) bool {
// for _, n := range arr {
// if n <= 0 {
// return false
// }
// }
// return true
// })(numbers),
// }
//
// assert.SequenceRecord(tests)(t)
// }
//
// # Comparison with TraverseRecord
//
// SequenceRecord and [TraverseRecord] are closely related:
//
// - SequenceRecord: Executes pre-defined assertions
//
// - Input: map[string]Reader (assertions already created)
//
// - Use when: You have already defined test cases with assertions
//
// - TraverseRecord: Transforms values into assertions
//
// - Input: map[string]T + function T -> Reader
//
// - Use when: You need to transform data before asserting
//
// # Comparison with SequenceSeq2
//
// SequenceRecord and [SequenceSeq2] serve similar purposes but differ in their input:
//
// - SequenceRecord: Works with maps
//
// - Input: map[string]Reader
//
// - Use when: You have named test cases in a map
//
// - Iteration order: Non-deterministic (map iteration)
//
// - SequenceSeq2: Works with iterators
//
// - Input: Seq2[string, Reader]
//
// - Use when: You have test cases in an iterator
//
// - Iteration order: Deterministic (iterator order)
//
// # Note on Map Iteration Order
//
// Go maps have non-deterministic iteration order. If test execution order matters,
// consider using [SequenceSeq2] with an iterator that provides deterministic ordering,
// or use [TraverseArray] with a slice of test cases.
//
// # Related Functions
//
// - [RunAll]: Alias for SequenceRecord
// - [TraverseRecord]: Similar but transforms values into assertions
// - [SequenceSeq2]: Similar but works with iterators
// - [TraverseArray]: Similar but works with arrays
//
// # References
//
// - Go subtests: https://go.dev/blog/subtests
// - Haskell sequence: https://hackage.haskell.org/package/base/docs/Data-Traversable.html#v:sequence
func SequenceRecord(m map[string]Reader) Reader {
return TraverseRecord(reader.Ask[Reader]())(m)
}

960
v2/assert/traverse_test.go Normal file
View File

@@ -0,0 +1,960 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"fmt"
"testing"
"github.com/IBM/fp-go/v2/pair"
)
// TestTraverseArray_EmptyArray tests that TraverseArray handles empty arrays correctly
func TestTraverseArray_EmptyArray(t *testing.T) {
traverse := TraverseArray(func(n int) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("test_%d", n),
Equal(n)(n),
)
})
result := traverse([]int{})(t)
if !result {
t.Error("Expected TraverseArray to pass with empty array")
}
}
// TestTraverseArray_SingleElement tests TraverseArray with a single element
func TestTraverseArray_SingleElement(t *testing.T) {
traverse := TraverseArray(func(n int) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("test_%d", n),
Equal(n*2)(n*2),
)
})
result := traverse([]int{5})(t)
if !result {
t.Error("Expected TraverseArray to pass with single element")
}
}
// TestTraverseArray_MultipleElements tests TraverseArray with multiple passing elements
func TestTraverseArray_MultipleElements(t *testing.T) {
traverse := TraverseArray(func(n int) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("square_%d", n),
Equal(n*n)(n*n),
)
})
result := traverse([]int{1, 2, 3, 4, 5})(t)
if !result {
t.Error("Expected TraverseArray to pass with all passing elements")
}
}
// TestTraverseArray_WithFailure tests that TraverseArray fails when one element fails
func TestTraverseArray_WithFailure(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
traverse := TraverseArray(func(n int) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("test_%d", n),
Equal(10)(n), // Will fail for all except 10
)
})
// Run in a subtest - we expect the subtests to fail, so t.Run returns false
result := traverse([]int{1, 2, 3})(t)
// The traverse should return false because assertions fail
if result {
t.Error("Expected traverse to return false when elements don't match")
}
}
// TestTraverseArray_MixedResults tests TraverseArray with some passing and some failing
func TestTraverseArray_MixedResults(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
traverse := TraverseArray(func(n int) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("is_even_%d", n),
Equal(0)(n%2), // Only passes for even numbers
)
})
result := traverse([]int{2, 3, 4})(t) // 3 is odd, should fail
// The traverse should return false because one assertion fails
if result {
t.Error("Expected traverse to return false when some elements fail")
}
}
// TestTraverseArray_StringData tests TraverseArray with string data
func TestTraverseArray_StringData(t *testing.T) {
words := []string{"hello", "world", "test"}
traverse := TraverseArray(func(s string) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("validate_%s", s),
AllOf([]Reader{
StringNotEmpty(s),
That(func(str string) bool { return len(str) > 0 })(s),
}),
)
})
result := traverse(words)(t)
if !result {
t.Error("Expected TraverseArray to pass with valid strings")
}
}
// TestTraverseArray_ComplexObjects tests TraverseArray with complex objects
func TestTraverseArray_ComplexObjects(t *testing.T) {
type User struct {
Name string
Age int
}
users := []User{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
{Name: "Charlie", Age: 35},
}
traverse := TraverseArray(func(u User) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("user_%s", u.Name),
AllOf([]Reader{
StringNotEmpty(u.Name),
That(func(age int) bool { return age > 0 && age < 150 })(u.Age),
}),
)
})
result := traverse(users)(t)
if !result {
t.Error("Expected TraverseArray to pass with valid users")
}
}
// TestTraverseArray_ComplexObjectsWithFailure tests TraverseArray with invalid complex objects
func TestTraverseArray_ComplexObjectsWithFailure(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
type User struct {
Name string
Age int
}
users := []User{
{Name: "Alice", Age: 30},
{Name: "", Age: 25}, // Invalid: empty name
{Name: "Charlie", Age: 35},
}
traverse := TraverseArray(func(u User) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("user_%s", u.Name),
AllOf([]Reader{
StringNotEmpty(u.Name),
That(func(age int) bool { return age > 0 })(u.Age),
}),
)
})
result := traverse(users)(t)
// The traverse should return false because one user is invalid
if result {
t.Error("Expected traverse to return false with invalid user")
}
}
// TestTraverseArray_DataDrivenTesting demonstrates data-driven testing pattern
func TestTraverseArray_DataDrivenTesting(t *testing.T) {
type TestCase struct {
Input int
Expected int
}
testCases := []TestCase{
{Input: 2, Expected: 4},
{Input: 3, Expected: 9},
{Input: 4, Expected: 16},
{Input: 5, Expected: 25},
}
square := func(n int) int { return n * n }
traverse := TraverseArray(func(tc TestCase) Pair[string, Reader] {
return pair.MakePair(
fmt.Sprintf("square(%d)=%d", tc.Input, tc.Expected),
Equal(tc.Expected)(square(tc.Input)),
)
})
result := traverse(testCases)(t)
if !result {
t.Error("Expected all test cases to pass")
}
}
// TestSequenceSeq2_EmptySequence tests that SequenceSeq2 handles empty sequences correctly
func TestSequenceSeq2_EmptySequence(t *testing.T) {
emptySeq := func(yield func(string, Reader) bool) {
// Empty - yields nothing
}
result := SequenceSeq2[Reader](emptySeq)(t)
if !result {
t.Error("Expected SequenceSeq2 to pass with empty sequence")
}
}
// TestSequenceSeq2_SingleTest tests SequenceSeq2 with a single test
func TestSequenceSeq2_SingleTest(t *testing.T) {
singleSeq := func(yield func(string, Reader) bool) {
yield("test_one", Equal(42)(42))
}
result := SequenceSeq2[Reader](singleSeq)(t)
if !result {
t.Error("Expected SequenceSeq2 to pass with single test")
}
}
// TestSequenceSeq2_MultipleTests tests SequenceSeq2 with multiple passing tests
func TestSequenceSeq2_MultipleTests(t *testing.T) {
multiSeq := func(yield func(string, Reader) bool) {
if !yield("test_addition", Equal(4)(2+2)) {
return
}
if !yield("test_subtraction", Equal(1)(3-2)) {
return
}
if !yield("test_multiplication", Equal(6)(2*3)) {
return
}
}
result := SequenceSeq2[Reader](multiSeq)(t)
if !result {
t.Error("Expected SequenceSeq2 to pass with all passing tests")
}
}
// TestSequenceSeq2_WithFailure tests that SequenceSeq2 fails when one test fails
func TestSequenceSeq2_WithFailure(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
failSeq := func(yield func(string, Reader) bool) {
if !yield("test_pass", Equal(4)(2+2)) {
return
}
if !yield("test_fail", Equal(5)(2+2)) { // This will fail
return
}
if !yield("test_pass2", Equal(6)(2*3)) {
return
}
}
result := SequenceSeq2[Reader](failSeq)(t)
// The sequence should return false because one test fails
if result {
t.Error("Expected sequence to return false when one test fails")
}
}
// TestSequenceSeq2_GeneratedTests tests SequenceSeq2 with generated test cases
func TestSequenceSeq2_GeneratedTests(t *testing.T) {
generateTests := func(yield func(string, Reader) bool) {
for i := 1; i <= 5; i++ {
name := fmt.Sprintf("test_%d", i)
assertion := Equal(i * i)(i * i)
if !yield(name, assertion) {
return
}
}
}
result := SequenceSeq2[Reader](generateTests)(t)
if !result {
t.Error("Expected all generated tests to pass")
}
}
// TestSequenceSeq2_StringTests tests SequenceSeq2 with string assertions
func TestSequenceSeq2_StringTests(t *testing.T) {
stringSeq := func(yield func(string, Reader) bool) {
if !yield("test_hello", StringNotEmpty("hello")) {
return
}
if !yield("test_world", StringNotEmpty("world")) {
return
}
if !yield("test_length", StringLength[any, any](5)("hello")) {
return
}
}
result := SequenceSeq2[Reader](stringSeq)(t)
if !result {
t.Error("Expected all string tests to pass")
}
}
// TestSequenceSeq2_ArrayTests tests SequenceSeq2 with array assertions
func TestSequenceSeq2_ArrayTests(t *testing.T) {
arr := []int{1, 2, 3, 4, 5}
arraySeq := func(yield func(string, Reader) bool) {
if !yield("test_not_empty", ArrayNotEmpty(arr)) {
return
}
if !yield("test_length", ArrayLength[int](5)(arr)) {
return
}
if !yield("test_contains", ArrayContains(3)(arr)) {
return
}
}
result := SequenceSeq2[Reader](arraySeq)(t)
if !result {
t.Error("Expected all array tests to pass")
}
}
// TestSequenceSeq2_ComplexAssertions tests SequenceSeq2 with complex combined assertions
func TestSequenceSeq2_ComplexAssertions(t *testing.T) {
type User struct {
Name string
Age int
Email string
}
user := User{Name: "Alice", Age: 30, Email: "alice@example.com"}
userSeq := func(yield func(string, Reader) bool) {
if !yield("test_name", StringNotEmpty(user.Name)) {
return
}
if !yield("test_age", That(func(age int) bool { return age > 0 && age < 150 })(user.Age)) {
return
}
if !yield("test_email", That(func(email string) bool {
for _, ch := range email {
if ch == '@' {
return true
}
}
return false
})(user.Email)) {
return
}
}
result := SequenceSeq2[Reader](userSeq)(t)
if !result {
t.Error("Expected all user validation tests to pass")
}
}
// TestSequenceSeq2_EarlyTermination tests that SequenceSeq2 respects early termination
func TestSequenceSeq2_EarlyTermination(t *testing.T) {
executionCount := 0
earlyTermSeq := func(yield func(string, Reader) bool) {
executionCount++
if !yield("test_1", Equal(1)(1)) {
return
}
executionCount++
if !yield("test_2", Equal(2)(2)) {
return
}
executionCount++
// This should execute even though we don't check the return
yield("test_3", Equal(3)(3))
executionCount++
}
SequenceSeq2[Reader](earlyTermSeq)(t)
// All iterations should execute since we're not terminating early
if executionCount != 4 {
t.Errorf("Expected 4 executions, got %d", executionCount)
}
}
// TestSequenceSeq2_WithMapConversion demonstrates converting a map to Seq2
func TestSequenceSeq2_WithMapConversion(t *testing.T) {
testMap := map[string]Reader{
"test_addition": Equal(4)(2 + 2),
"test_multiplication": Equal(6)(2 * 3),
"test_subtraction": Equal(1)(3 - 2),
}
// Convert map to Seq2
mapSeq := func(yield func(string, Reader) bool) {
for name, assertion := range testMap {
if !yield(name, assertion) {
return
}
}
}
result := SequenceSeq2[Reader](mapSeq)(t)
if !result {
t.Error("Expected all map-based tests to pass")
}
}
// TestTraverseArray_vs_SequenceSeq2 demonstrates the relationship between the two functions
func TestTraverseArray_vs_SequenceSeq2(t *testing.T) {
type TestCase struct {
Name string
Input int
Expected int
}
testCases := []TestCase{
{Name: "test_1", Input: 2, Expected: 4},
{Name: "test_2", Input: 3, Expected: 9},
{Name: "test_3", Input: 4, Expected: 16},
}
// Using TraverseArray
traverseResult := TraverseArray(func(tc TestCase) Pair[string, Reader] {
return pair.MakePair(tc.Name, Equal(tc.Expected)(tc.Input*tc.Input))
})(testCases)(t)
// Using SequenceSeq2
seqResult := SequenceSeq2[Reader](func(yield func(string, Reader) bool) {
for _, tc := range testCases {
if !yield(tc.Name, Equal(tc.Expected)(tc.Input*tc.Input)) {
return
}
}
})(t)
if traverseResult != seqResult {
t.Error("Expected TraverseArray and SequenceSeq2 to produce same result")
}
if !traverseResult || !seqResult {
t.Error("Expected both approaches to pass")
}
}
// TestTraverseRecord_EmptyMap tests that TraverseRecord handles empty maps correctly
func TestTraverseRecord_EmptyMap(t *testing.T) {
traverse := TraverseRecord(func(n int) Reader {
return Equal(n)(n)
})
result := traverse(map[string]int{})(t)
if !result {
t.Error("Expected TraverseRecord to pass with empty map")
}
}
// TestTraverseRecord_SingleEntry tests TraverseRecord with a single map entry
func TestTraverseRecord_SingleEntry(t *testing.T) {
traverse := TraverseRecord(func(n int) Reader {
return Equal(n * 2)(n * 2)
})
result := traverse(map[string]int{"test_5": 5})(t)
if !result {
t.Error("Expected TraverseRecord to pass with single entry")
}
}
// TestTraverseRecord_MultipleEntries tests TraverseRecord with multiple passing entries
func TestTraverseRecord_MultipleEntries(t *testing.T) {
traverse := TraverseRecord(func(n int) Reader {
return Equal(n * n)(n * n)
})
result := traverse(map[string]int{
"square_1": 1,
"square_2": 2,
"square_3": 3,
"square_4": 4,
"square_5": 5,
})(t)
if !result {
t.Error("Expected TraverseRecord to pass with all passing entries")
}
}
// TestTraverseRecord_WithFailure tests that TraverseRecord fails when one entry fails
func TestTraverseRecord_WithFailure(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
traverse := TraverseRecord(func(n int) Reader {
return Equal(10)(n) // Will fail for all except 10
})
result := traverse(map[string]int{
"test_1": 1,
"test_2": 2,
"test_3": 3,
})(t)
// The traverse should return false because entries don't match
if result {
t.Error("Expected traverse to return false when entries don't match")
}
}
// TestTraverseRecord_MixedResults tests TraverseRecord with some passing and some failing
func TestTraverseRecord_MixedResults(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
traverse := TraverseRecord(func(n int) Reader {
return Equal(0)(n % 2) // Only passes for even numbers
})
result := traverse(map[string]int{
"even_2": 2,
"odd_3": 3,
"even_4": 4,
})(t)
// The traverse should return false because some entries fail
if result {
t.Error("Expected traverse to return false when some entries fail")
}
}
// TestTraverseRecord_StringData tests TraverseRecord with string data
func TestTraverseRecord_StringData(t *testing.T) {
words := map[string]string{
"greeting": "hello",
"world": "world",
"test": "test",
}
traverse := TraverseRecord(func(s string) Reader {
return AllOf([]Reader{
StringNotEmpty(s),
That(func(str string) bool { return len(str) > 0 })(s),
})
})
result := traverse(words)(t)
if !result {
t.Error("Expected TraverseRecord to pass with valid strings")
}
}
// TestTraverseRecord_ComplexObjects tests TraverseRecord with complex objects
func TestTraverseRecord_ComplexObjects(t *testing.T) {
type User struct {
Name string
Age int
}
users := map[string]User{
"alice": {Name: "Alice", Age: 30},
"bob": {Name: "Bob", Age: 25},
"charlie": {Name: "Charlie", Age: 35},
}
traverse := TraverseRecord(func(u User) Reader {
return AllOf([]Reader{
StringNotEmpty(u.Name),
That(func(age int) bool { return age > 0 && age < 150 })(u.Age),
})
})
result := traverse(users)(t)
if !result {
t.Error("Expected TraverseRecord to pass with valid users")
}
}
// TestTraverseRecord_ComplexObjectsWithFailure tests TraverseRecord with invalid complex objects
func TestTraverseRecord_ComplexObjectsWithFailure(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
type User struct {
Name string
Age int
}
users := map[string]User{
"alice": {Name: "Alice", Age: 30},
"invalid": {Name: "", Age: 25}, // Invalid: empty name
"charlie": {Name: "Charlie", Age: 35},
}
traverse := TraverseRecord(func(u User) Reader {
return AllOf([]Reader{
StringNotEmpty(u.Name),
That(func(age int) bool { return age > 0 })(u.Age),
})
})
result := traverse(users)(t)
// The traverse should return false because one user is invalid
if result {
t.Error("Expected traverse to return false with invalid user")
}
}
// TestTraverseRecord_ConfigurationTesting demonstrates configuration testing pattern
func TestTraverseRecord_ConfigurationTesting(t *testing.T) {
configs := map[string]int{
"timeout": 30,
"maxRetries": 3,
"bufferSize": 1024,
}
validatePositive := That(func(n int) bool { return n > 0 })
traverse := TraverseRecord(validatePositive)
result := traverse(configs)(t)
if !result {
t.Error("Expected all configuration values to be positive")
}
}
// TestTraverseRecord_APIEndpointTesting demonstrates API endpoint testing pattern
func TestTraverseRecord_APIEndpointTesting(t *testing.T) {
type Endpoint struct {
Path string
Method string
}
endpoints := map[string]Endpoint{
"get_users": {Path: "/api/users", Method: "GET"},
"create_user": {Path: "/api/users", Method: "POST"},
"delete_user": {Path: "/api/users/:id", Method: "DELETE"},
}
validateEndpoint := func(e Endpoint) Reader {
return AllOf([]Reader{
StringNotEmpty(e.Path),
That(func(path string) bool {
return len(path) > 0 && path[0] == '/'
})(e.Path),
That(func(method string) bool {
return method == "GET" || method == "POST" ||
method == "PUT" || method == "DELETE"
})(e.Method),
})
}
traverse := TraverseRecord(validateEndpoint)
result := traverse(endpoints)(t)
if !result {
t.Error("Expected all endpoints to be valid")
}
}
// TestSequenceRecord_EmptyMap tests that SequenceRecord handles empty maps correctly
func TestSequenceRecord_EmptyMap(t *testing.T) {
result := SequenceRecord(map[string]Reader{})(t)
if !result {
t.Error("Expected SequenceRecord to pass with empty map")
}
}
// TestSequenceRecord_SingleTest tests SequenceRecord with a single test
func TestSequenceRecord_SingleTest(t *testing.T) {
tests := map[string]Reader{
"test_one": Equal(42)(42),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected SequenceRecord to pass with single test")
}
}
// TestSequenceRecord_MultipleTests tests SequenceRecord with multiple passing tests
func TestSequenceRecord_MultipleTests(t *testing.T) {
tests := map[string]Reader{
"test_addition": Equal(4)(2 + 2),
"test_subtraction": Equal(1)(3 - 2),
"test_multiplication": Equal(6)(2 * 3),
"test_division": Equal(2)(6 / 3),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected SequenceRecord to pass with all passing tests")
}
}
// TestSequenceRecord_WithFailure tests that SequenceRecord fails when one test fails
func TestSequenceRecord_WithFailure(t *testing.T) {
t.Skip("Skipping test that intentionally creates failing subtests")
tests := map[string]Reader{
"test_pass": Equal(4)(2 + 2),
"test_fail": Equal(5)(2 + 2), // This will fail
"test_pass2": Equal(6)(2 * 3),
}
result := SequenceRecord(tests)(t)
// The sequence should return false because one test fails
if result {
t.Error("Expected sequence to return false when one test fails")
}
}
// TestSequenceRecord_StringTests tests SequenceRecord with string assertions
func TestSequenceRecord_StringTests(t *testing.T) {
testString := "hello world"
tests := map[string]Reader{
"not_empty": StringNotEmpty(testString),
"correct_length": StringLength[any, any](11)(testString),
"has_space": That(func(s string) bool {
for _, ch := range s {
if ch == ' ' {
return true
}
}
return false
})(testString),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected all string tests to pass")
}
}
// TestSequenceRecord_ArrayTests tests SequenceRecord with array assertions
func TestSequenceRecord_ArrayTests(t *testing.T) {
arr := []int{1, 2, 3, 4, 5}
tests := map[string]Reader{
"not_empty": ArrayNotEmpty(arr),
"correct_length": ArrayLength[int](5)(arr),
"contains_three": ArrayContains(3)(arr),
"all_positive": That(func(arr []int) bool {
for _, n := range arr {
if n <= 0 {
return false
}
}
return true
})(arr),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected all array tests to pass")
}
}
// TestSequenceRecord_ComplexAssertions tests SequenceRecord with complex combined assertions
func TestSequenceRecord_ComplexAssertions(t *testing.T) {
type User struct {
Name string
Age int
Email string
}
user := User{Name: "Alice", Age: 30, Email: "alice@example.com"}
tests := map[string]Reader{
"name_not_empty": StringNotEmpty(user.Name),
"age_positive": That(func(age int) bool { return age > 0 })(user.Age),
"age_reasonable": That(func(age int) bool { return age < 150 })(user.Age),
"email_valid": That(func(email string) bool {
hasAt := false
hasDot := false
for _, ch := range email {
if ch == '@' {
hasAt = true
}
if ch == '.' {
hasDot = true
}
}
return hasAt && hasDot
})(user.Email),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected all user validation tests to pass")
}
}
// TestSequenceRecord_MathOperations demonstrates basic math operations testing
func TestSequenceRecord_MathOperations(t *testing.T) {
tests := map[string]Reader{
"addition": Equal(4)(2 + 2),
"subtraction": Equal(1)(3 - 2),
"multiplication": Equal(6)(2 * 3),
"division": Equal(2)(6 / 3),
"modulo": Equal(1)(7 % 3),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected all math operations to pass")
}
}
// TestSequenceRecord_BooleanTests tests SequenceRecord with boolean assertions
func TestSequenceRecord_BooleanTests(t *testing.T) {
tests := map[string]Reader{
"true_is_true": Equal(true)(true),
"false_is_false": Equal(false)(false),
"not_true": Equal(false)(!true),
"not_false": Equal(true)(!false),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected all boolean tests to pass")
}
}
// TestSequenceRecord_ErrorTests tests SequenceRecord with error assertions
func TestSequenceRecord_ErrorTests(t *testing.T) {
tests := map[string]Reader{
"no_error": NoError(nil),
"equal_value": Equal("test")("test"),
"not_empty": StringNotEmpty("hello"),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected all error tests to pass")
}
}
// TestTraverseRecord_vs_SequenceRecord demonstrates the relationship between the two functions
func TestTraverseRecord_vs_SequenceRecord(t *testing.T) {
type TestCase struct {
Input int
Expected int
}
testData := map[string]TestCase{
"test_1": {Input: 2, Expected: 4},
"test_2": {Input: 3, Expected: 9},
"test_3": {Input: 4, Expected: 16},
}
// Using TraverseRecord
traverseResult := TraverseRecord(func(tc TestCase) Reader {
return Equal(tc.Expected)(tc.Input * tc.Input)
})(testData)(t)
// Using SequenceRecord (manually creating the map)
tests := make(map[string]Reader)
for name, tc := range testData {
tests[name] = Equal(tc.Expected)(tc.Input * tc.Input)
}
seqResult := SequenceRecord(tests)(t)
if traverseResult != seqResult {
t.Error("Expected TraverseRecord and SequenceRecord to produce same result")
}
if !traverseResult || !seqResult {
t.Error("Expected both approaches to pass")
}
}
// TestSequenceRecord_WithAllOf demonstrates combining SequenceRecord with AllOf
func TestSequenceRecord_WithAllOf(t *testing.T) {
arr := []int{1, 2, 3, 4, 5}
tests := map[string]Reader{
"array_validations": AllOf([]Reader{
ArrayNotEmpty(arr),
ArrayLength[int](5)(arr),
ArrayContains(3)(arr),
}),
"element_checks": AllOf([]Reader{
That(func(a []int) bool { return a[0] == 1 })(arr),
That(func(a []int) bool { return a[4] == 5 })(arr),
}),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected combined assertions to pass")
}
}
// TestTraverseRecord_ConfigValidation demonstrates real-world configuration validation
func TestTraverseRecord_ConfigValidation(t *testing.T) {
type Config struct {
Value int
Min int
Max int
}
configs := map[string]Config{
"timeout": {Value: 30, Min: 1, Max: 60},
"maxRetries": {Value: 3, Min: 1, Max: 10},
"bufferSize": {Value: 1024, Min: 512, Max: 4096},
}
validateConfig := func(c Config) Reader {
return AllOf([]Reader{
That(func(val int) bool { return val >= c.Min })(c.Value),
That(func(val int) bool { return val <= c.Max })(c.Value),
})
}
traverse := TraverseRecord(validateConfig)
result := traverse(configs)(t)
if !result {
t.Error("Expected all configurations to be within valid ranges")
}
}
// TestSequenceRecord_RealWorldExample demonstrates a realistic use case
func TestSequenceRecord_RealWorldExample(t *testing.T) {
type Response struct {
StatusCode int
Body string
}
response := Response{StatusCode: 200, Body: `{"status":"ok"}`}
tests := map[string]Reader{
"status_ok": Equal(200)(response.StatusCode),
"body_not_empty": StringNotEmpty(response.Body),
"body_is_json": That(func(s string) bool {
return len(s) > 0 && s[0] == '{' && s[len(s)-1] == '}'
})(response.Body),
}
result := SequenceRecord(tests)(t)
if !result {
t.Error("Expected response validation to pass")
}
}

View File

@@ -1,11 +1,32 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package assert
import (
"iter"
"testing"
"github.com/IBM/fp-go/v2/context/readerio"
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/optional"
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
@@ -13,23 +34,506 @@ import (
type (
// Result represents a computation that may fail with an error.
//
// This is an alias for [result.Result][T], which encapsulates either a successful
// value of type T or an error. It's commonly used in test assertions to represent
// operations that might fail, allowing for functional error handling without exceptions.
//
// A Result can be in one of two states:
// - Success: Contains a value of type T
// - Failure: Contains an error
//
// This type is particularly useful in testing scenarios where you need to:
// - Test functions that return results
// - Chain operations that might fail
// - Handle errors functionally
//
// Example:
//
// func TestResultHandling(t *testing.T) {
// successResult := result.Of[int](42)
// assert.Success(successResult)(t) // Passes
//
// failureResult := result.Error[int](errors.New("failed"))
// assert.Failure(failureResult)(t) // Passes
// }
//
// See also:
// - [Success]: Asserts a Result is successful
// - [Failure]: Asserts a Result contains an error
// - [result.Result]: The underlying Result type
Result[T any] = result.Result[T]
// Reader represents a test assertion that depends on a testing.T context and returns a boolean.
// Reader represents a test assertion that depends on a [testing.T] context and returns a boolean.
//
// This is the core type for all assertions in this package. It's an alias for
// [reader.Reader][*testing.T, bool], which is a function that takes a testing context
// and produces a boolean result indicating whether the assertion passed.
//
// The Reader pattern enables:
// - Composable assertions that can be combined using functional operators
// - Deferred execution - assertions are defined but not executed until applied to a test
// - Reusable assertion logic that can be applied to multiple tests
// - Functional composition of complex test conditions
//
// All assertion functions in this package return a Reader, which must be applied
// to a *testing.T to execute the assertion:
//
// assertion := assert.Equal(42)(result) // Creates a Reader
// assertion(t) // Executes the assertion
//
// Readers can be composed using functions like [AllOf], [ApplicativeMonoid], or
// functional operators from the reader package.
//
// Example:
//
// func TestReaderComposition(t *testing.T) {
// // Create individual assertions
// assertion1 := assert.Equal(42)(42)
// assertion2 := assert.StringNotEmpty("hello")
//
// // Combine them
// combined := assert.AllOf([]assert.Reader{assertion1, assertion2})
//
// // Execute the combined assertion
// combined(t)
// }
//
// See also:
// - [Kleisli]: Function that produces a Reader from a value
// - [AllOf]: Combines multiple Readers
// - [ApplicativeMonoid]: Monoid for combining Readers
Reader = reader.Reader[*testing.T, bool]
// Kleisli represents a function that produces a test assertion Reader from a value of type T.
// Kleisli represents a function that produces a test assertion [Reader] from a value of type T.
//
// This is an alias for [reader.Reader][T, Reader], which is a function that takes a value
// of type T and returns a Reader (test assertion). This pattern is fundamental to the
// "data last" principle used throughout this package.
//
// Kleisli functions enable:
// - Partial application of assertions - configure the expected value first, apply actual value later
// - Reusable assertion builders that can be applied to different values
// - Functional composition of assertion pipelines
// - Point-free style programming with assertions
//
// Most assertion functions in this package return a Kleisli, which must be applied
// to the actual value being tested, and then to a *testing.T:
//
// kleisli := assert.Equal(42) // Kleisli[int] - expects an int
// reader := kleisli(result) // Reader - assertion ready to execute
// reader(t) // Execute the assertion
//
// Or more concisely:
//
// assert.Equal(42)(result)(t)
//
// Example:
//
// func TestKleisliPattern(t *testing.T) {
// // Create a reusable assertion for positive numbers
// isPositive := assert.That(func(n int) bool { return n > 0 })
//
// // Apply it to different values
// isPositive(42)(t) // Passes
// isPositive(100)(t) // Passes
// // isPositive(-5)(t) would fail
//
// // Can be used with Local for property testing
// type User struct { Age int }
// checkAge := assert.Local(func(u User) int { return u.Age })(isPositive)
// checkAge(User{Age: 25})(t) // Passes
// }
//
// See also:
// - [Reader]: The assertion type produced by Kleisli
// - [Local]: Focuses a Kleisli on a property of a larger structure
Kleisli[T any] = reader.Reader[T, Reader]
// Predicate represents a function that tests a value of type T and returns a boolean.
//
// This is an alias for [predicate.Predicate][T], which is a simple function that
// takes a value and returns true or false based on some condition. Predicates are
// used with the [That] function to create custom assertions.
//
// Predicates enable:
// - Custom validation logic for any type
// - Reusable test conditions
// - Composition of complex validation rules
// - Integration with functional programming patterns
//
// Example:
//
// func TestPredicates(t *testing.T) {
// // Simple predicate
// isEven := func(n int) bool { return n%2 == 0 }
// assert.That(isEven)(42)(t) // Passes
//
// // String predicate
// hasPrefix := func(s string) bool { return strings.HasPrefix(s, "test") }
// assert.That(hasPrefix)("test_file.go")(t) // Passes
//
// // Complex predicate
// isValidEmail := func(s string) bool {
// return strings.Contains(s, "@") && strings.Contains(s, ".")
// }
// assert.That(isValidEmail)("user@example.com")(t) // Passes
// }
//
// See also:
// - [That]: Creates an assertion from a Predicate
// - [predicate.Predicate]: The underlying predicate type
Predicate[T any] = predicate.Predicate[T]
// Lens is a functional reference to a subpart of a data structure.
//
// This is an alias for [lens.Lens][S, T], which provides a composable way to focus
// on a specific field within a larger structure. Lenses enable getting and setting
// values in nested data structures in a functional, immutable way.
//
// In the context of testing, lenses are used with [LocalL] to focus assertions
// on specific properties of complex objects without manually extracting those properties.
//
// A Lens[S, T] focuses on a value of type T within a structure of type S.
//
// Example:
//
// func TestLensUsage(t *testing.T) {
// type Address struct { City string }
// type User struct { Name string; Address Address }
//
// // Define lenses (typically generated)
// addressLens := lens.Lens[User, Address]{...}
// cityLens := lens.Lens[Address, string]{...}
//
// // Compose lenses to focus on nested field
// userCityLens := lens.Compose(addressLens, cityLens)
//
// // Use with LocalL to assert on nested property
// user := User{Name: "Alice", Address: Address{City: "NYC"}}
// assert.LocalL(userCityLens)(assert.Equal("NYC"))(user)(t)
// }
//
// See also:
// - [LocalL]: Uses a Lens to focus assertions on a property
// - [lens.Lens]: The underlying lens type
// - [Optional]: Similar but for values that may not exist
Lens[S, T any] = lens.Lens[S, T]
// Optional is an optic that focuses on a value that may or may not be present.
//
// This is an alias for [optional.Optional][S, T], which is similar to a [Lens] but
// handles cases where the focused value might not exist. Optionals are useful for
// working with nullable fields, optional properties, or values that might be absent.
//
// In testing, Optionals are used with [FromOptional] to create assertions that
// verify whether an optional value is present and, if so, whether it satisfies
// certain conditions.
//
// An Optional[S, T] focuses on an optional value of type T within a structure of type S.
//
// Example:
//
// func TestOptionalUsage(t *testing.T) {
// type Config struct { Timeout *int }
//
// // Define optional (typically generated)
// timeoutOptional := optional.Optional[Config, int]{...}
//
// // Test when value is present
// config1 := Config{Timeout: ptr(30)}
// assert.FromOptional(timeoutOptional)(
// assert.Equal(30),
// )(config1)(t) // Passes
//
// // Test when value is absent
// config2 := Config{Timeout: nil}
// // FromOptional would fail because value is not present
// }
//
// See also:
// - [FromOptional]: Creates assertions for optional values
// - [optional.Optional]: The underlying optional type
// - [Lens]: Similar but for values that always exist
Optional[S, T any] = optional.Optional[S, T]
// Prism is an optic that focuses on a case of a sum type.
//
// This is an alias for [prism.Prism][S, T], which provides a way to focus on one
// variant of a sum type (like Result, Option, Either, etc.). Prisms enable pattern
// matching and extraction of values from sum types in a functional way.
//
// In testing, Prisms are used with [FromPrism] to create assertions that verify
// whether a value matches a specific case and, if so, whether the contained value
// satisfies certain conditions.
//
// A Prism[S, T] focuses on a value of type T that may be contained within a sum type S.
//
// Example:
//
// func TestPrismUsage(t *testing.T) {
// // Prism for extracting success value from Result
// successPrism := prism.Success[int]()
//
// // Test successful result
// successResult := result.Of[int](42)
// assert.FromPrism(successPrism)(
// assert.Equal(42),
// )(successResult)(t) // Passes
//
// // Prism for extracting error from Result
// failurePrism := prism.Failure[int]()
//
// // Test failed result
// failureResult := result.Error[int](errors.New("failed"))
// assert.FromPrism(failurePrism)(
// assert.Error,
// )(failureResult)(t) // Passes
// }
//
// See also:
// - [FromPrism]: Creates assertions for prism-focused values
// - [prism.Prism]: The underlying prism type
// - [Optional]: Similar but for optional values
Prism[S, T any] = prism.Prism[S, T]
// ReaderIOResult represents a context-aware, IO-based computation that may fail.
//
// This is an alias for [readerioresult.ReaderIOResult][A], which combines three
// computational effects:
// - Reader: Depends on a context (like context.Context)
// - IO: Performs side effects (like file I/O, network calls)
// - Result: May fail with an error
//
// In testing, ReaderIOResult is used with [FromReaderIOResult] to convert
// context-aware, effectful computations into test assertions. This is useful
// when your test assertions need to:
// - Access a context for cancellation or deadlines
// - Perform IO operations (database queries, API calls, file access)
// - Handle potential errors gracefully
//
// Example:
//
// func TestReaderIOResult(t *testing.T) {
// // Create a ReaderIOResult that performs IO and may fail
// checkDatabase := func(ctx context.Context) func() result.Result[assert.Reader] {
// return func() result.Result[assert.Reader] {
// // Perform database check with context
// if err := db.PingContext(ctx); err != nil {
// return result.Error[assert.Reader](err)
// }
// return result.Of[assert.Reader](assert.NoError(nil))
// }
// }
//
// // Convert to Reader and execute
// assertion := assert.FromReaderIOResult(checkDatabase)
// assertion(t)
// }
//
// See also:
// - [FromReaderIOResult]: Converts ReaderIOResult to Reader
// - [ReaderIO]: Similar but without error handling
// - [readerioresult.ReaderIOResult]: The underlying type
ReaderIOResult[A any] = readerioresult.ReaderIOResult[A]
// ReaderIO represents a context-aware, IO-based computation.
//
// This is an alias for [readerio.ReaderIO][A], which combines two computational effects:
// - Reader: Depends on a context (like context.Context)
// - IO: Performs side effects (like logging, metrics)
//
// In testing, ReaderIO is used with [FromReaderIO] to convert context-aware,
// effectful computations into test assertions. This is useful when your test
// assertions need to:
// - Access a context for cancellation or deadlines
// - Perform IO operations that don't fail (or handle failures internally)
// - Integrate with context-aware utilities
//
// Example:
//
// func TestReaderIO(t *testing.T) {
// // Create a ReaderIO that performs IO
// logAndCheck := func(ctx context.Context) func() assert.Reader {
// return func() assert.Reader {
// // Log with context
// logger.InfoContext(ctx, "Running test")
// // Return assertion
// return assert.Equal(42)(computeValue())
// }
// }
//
// // Convert to Reader and execute
// assertion := assert.FromReaderIO(logAndCheck)
// assertion(t)
// }
//
// See also:
// - [FromReaderIO]: Converts ReaderIO to Reader
// - [ReaderIOResult]: Similar but with error handling
// - [readerio.ReaderIO]: The underlying type
ReaderIO[A any] = readerio.ReaderIO[A]
// Seq2 represents a Go iterator that yields key-value pairs.
//
// This is an alias for [iter.Seq2][K, A], which is Go's standard iterator type
// introduced in Go 1.23. It represents a sequence of key-value pairs that can be
// iterated over using a for-range loop.
//
// In testing, Seq2 is used with [SequenceSeq2] to execute a sequence of named
// test cases provided as an iterator. This enables:
// - Lazy evaluation of test cases
// - Memory-efficient testing of large test suites
// - Integration with Go's iterator patterns
// - Dynamic generation of test cases
//
// Example:
//
// func TestSeq2Usage(t *testing.T) {
// // Create an iterator of test cases
// testCases := func(yield func(string, assert.Reader) bool) {
// if !yield("test_addition", assert.Equal(4)(2+2)) {
// return
// }
// if !yield("test_multiplication", assert.Equal(6)(2*3)) {
// return
// }
// }
//
// // Execute all test cases
// assert.SequenceSeq2[assert.Reader](testCases)(t)
// }
//
// See also:
// - [SequenceSeq2]: Executes a Seq2 of test cases
// - [TraverseArray]: Similar but for arrays
// - [iter.Seq2]: The underlying iterator type
Seq2[K, A any] = iter.Seq2[K, A]
// Pair represents a tuple of two values with potentially different types.
//
// This is an alias for [pair.Pair][L, R], which holds two values: a "head" (or "left")
// of type L and a "tail" (or "right") of type R. Pairs are useful for grouping
// related values together without defining a custom struct.
//
// In testing, Pairs are used with [TraverseArray] to associate test names with
// their corresponding assertions. Each element in the array is transformed into
// a Pair[string, Reader] where the string is the test name and the Reader is
// the assertion to execute.
//
// Example:
//
// func TestPairUsage(t *testing.T) {
// type TestCase struct {
// Input int
// Expected int
// }
//
// testCases := []TestCase{
// {Input: 2, Expected: 4},
// {Input: 3, Expected: 9},
// }
//
// // Transform each test case into a named assertion
// traverse := assert.TraverseArray(func(tc TestCase) assert.Pair[string, assert.Reader] {
// name := fmt.Sprintf("square(%d)=%d", tc.Input, tc.Expected)
// assertion := assert.Equal(tc.Expected)(tc.Input * tc.Input)
// return pair.MakePair(name, assertion)
// })
//
// traverse(testCases)(t)
// }
//
// See also:
// - [TraverseArray]: Uses Pairs to create named test cases
// - [pair.Pair]: The underlying pair type
// - [pair.MakePair]: Creates a Pair
// - [pair.Head]: Extracts the first value
// - [pair.Tail]: Extracts the second value
Pair[L, R any] = pair.Pair[L, R]
// Void represents the absence of a meaningful value, similar to unit type in functional programming.
//
// This is an alias for [function.Void], which is used to represent operations that don't
// return a meaningful value but may perform side effects. In the context of testing, Void
// is used with IO operations that perform actions without producing a result.
//
// Void is conceptually similar to:
// - Unit type in functional languages (Haskell's (), Scala's Unit)
// - void in languages like C/Java (but as a value, not just a type)
// - Empty struct{} in Go (but with clearer semantic meaning)
//
// Example:
//
// func TestWithSideEffect(t *testing.T) {
// // An IO operation that logs but returns Void
// logOperation := func() function.Void {
// log.Println("Test executed")
// return function.Void{}
// }
//
// // Execute the operation
// logOperation()
// }
//
// See also:
// - [IO]: Wraps side-effecting operations
// - [function.Void]: The underlying void type
Void = function.Void
// IO represents a side-effecting computation that produces a value of type A.
//
// This is an alias for [io.IO][A], which encapsulates operations that perform side effects
// (like I/O operations, logging, or state mutations) and return a value. IO is a lazy
// computation - it describes an effect but doesn't execute it until explicitly run.
//
// In testing, IO is used to:
// - Defer execution of side effects until needed
// - Compose multiple side-effecting operations
// - Maintain referential transparency in test setup
// - Separate effect description from effect execution
//
// An IO[A] is essentially a function `func() A` that:
// - Encapsulates a side effect
// - Returns a value of type A when executed
// - Can be composed with other IO operations
//
// Example:
//
// func TestIOOperation(t *testing.T) {
// // Define an IO operation that reads a file
// readConfig := func() io.IO[string] {
// return func() string {
// data, _ := os.ReadFile("config.txt")
// return string(data)
// }
// }
//
// // The IO is not executed yet - it's just a description
// configIO := readConfig()
//
// // Execute the IO to get the result
// config := configIO()
// assert.StringNotEmpty(config)(t)
// }
//
// Example with composition:
//
// func TestIOComposition(t *testing.T) {
// // Chain multiple IO operations
// pipeline := io.Map(
// func(s string) int { return len(s) },
// )(readFileIO)
//
// // Execute the composed operation
// length := pipeline()
// assert.That(func(n int) bool { return n > 0 })(length)(t)
// }
//
// See also:
// - [ReaderIO]: Combines Reader and IO effects
// - [ReaderIOResult]: Adds error handling to ReaderIO
// - [io.IO]: The underlying IO type
// - [Void]: Represents operations without meaningful return values
IO[A any] = io.IO[A]
)

273
v2/cli/README.md Normal file
View File

@@ -0,0 +1,273 @@
# CLI Package - Functional Wrappers for urfave/cli/v3
This package provides functional programming wrappers for the `github.com/urfave/cli/v3` library, enabling Effect-based command actions and type-safe flag handling through Prisms.
## Features
### 1. Effect-Based Command Actions
Transform CLI command actions into composable Effects that follow functional programming principles.
#### Key Functions
- **`ToAction(effect CommandEffect) func(context.Context, *C.Command) error`**
- Converts a CommandEffect into a standard urfave/cli Action function
- Enables Effect-based command handlers to work with cli/v3 framework
- **`FromAction(action func(context.Context, *C.Command) error) CommandEffect`**
- Lifts existing cli/v3 action handlers into the Effect type
- Allows gradual migration to functional style
- **`MakeCommand(name, usage string, flags []C.Flag, effect CommandEffect) *C.Command`**
- Creates a new Command with an Effect-based action
- Convenience function combining command creation with Effect conversion
- **`MakeCommandWithSubcommands(...) *C.Command`**
- Creates a Command with subcommands and an Effect-based action
#### Example Usage
```go
import (
"context"
E "github.com/IBM/fp-go/v2/effect"
F "github.com/IBM/fp-go/v2/function"
R "github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/cli"
C "github.com/urfave/cli/v3"
)
// Define an Effect-based command action
processEffect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
input := cmd.String("input")
// Process input...
return R.Of(F.Void{})
}
}
}
// Create command with Effect
command := cli.MakeCommand(
"process",
"Process input files",
[]C.Flag{
&C.StringFlag{Name: "input", Usage: "Input file path"},
},
processEffect,
)
// Or convert existing action to Effect
existingAction := func(ctx context.Context, cmd *C.Command) error {
// Existing logic...
return nil
}
effect := cli.FromAction(existingAction)
```
### 2. Flag Type Prisms
Type-safe extraction and manipulation of CLI flags using Prisms from the optics package.
#### Available Prisms
- `StringFlagPrism()` - Extract `*C.StringFlag` from `C.Flag`
- `IntFlagPrism()` - Extract `*C.IntFlag` from `C.Flag`
- `BoolFlagPrism()` - Extract `*C.BoolFlag` from `C.Flag`
- `Float64FlagPrism()` - Extract `*C.Float64Flag` from `C.Flag`
- `DurationFlagPrism()` - Extract `*C.DurationFlag` from `C.Flag`
- `TimestampFlagPrism()` - Extract `*C.TimestampFlag` from `C.Flag`
- `StringSliceFlagPrism()` - Extract `*C.StringSliceFlag` from `C.Flag`
- `IntSliceFlagPrism()` - Extract `*C.IntSliceFlag` from `C.Flag`
- `Float64SliceFlagPrism()` - Extract `*C.Float64SliceFlag` from `C.Flag`
- `UintFlagPrism()` - Extract `*C.UintFlag` from `C.Flag`
- `Uint64FlagPrism()` - Extract `*C.Uint64Flag` from `C.Flag`
- `Int64FlagPrism()` - Extract `*C.Int64Flag` from `C.Flag`
#### Example Usage
```go
import (
O "github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/cli"
C "github.com/urfave/cli/v3"
)
// Extract a StringFlag from a Flag interface
var flag C.Flag = &C.StringFlag{Name: "input", Value: "default"}
prism := cli.StringFlagPrism()
// Safe extraction returns Option
result := prism.GetOption(flag)
if O.IsSome(result) {
strFlag := O.MonadFold(result,
func() *C.StringFlag { return nil },
func(f *C.StringFlag) *C.StringFlag { return f },
)
// Use strFlag...
}
// Type mismatch returns None
var intFlag C.Flag = &C.IntFlag{Name: "count"}
result = prism.GetOption(intFlag) // Returns None
// Convert back to Flag
strFlag := &C.StringFlag{Name: "output"}
flag = prism.ReverseGet(strFlag)
```
## Type Definitions
### CommandEffect
```go
type CommandEffect = E.Effect[*C.Command, F.Void]
```
A CommandEffect represents a CLI command action as an Effect. It takes a `*C.Command` as context and produces a result wrapped in the Effect monad.
The Effect structure is:
```
func(*C.Command) -> func(context.Context) -> func() -> Result[Void]
```
This allows for:
- **Composability**: Effects can be composed using standard functional combinators
- **Testability**: Pure functions are easier to test
- **Error Handling**: Errors are explicitly represented in the Result type
- **Context Management**: Context flows naturally through the Effect
## Benefits
### 1. Functional Composition
Effects can be composed using standard functional programming patterns:
```go
import (
F "github.com/IBM/fp-go/v2/function"
RRIOE "github.com/IBM/fp-go/v2/context/readerreaderioresult"
)
// Compose multiple effects
validateInput := func(cmd *C.Command) E.Thunk[F.Void] { /* ... */ }
processData := func(cmd *C.Command) E.Thunk[F.Void] { /* ... */ }
saveResults := func(cmd *C.Command) E.Thunk[F.Void] { /* ... */ }
// Chain effects together
pipeline := F.Pipe3(
validateInput,
RRIOE.Chain(func(F.Void) E.Effect[*C.Command, F.Void] { return processData }),
RRIOE.Chain(func(F.Void) E.Effect[*C.Command, F.Void] { return saveResults }),
)
```
### 2. Type Safety
Prisms provide compile-time type safety when working with flags:
```go
// Type-safe flag extraction
flags := []C.Flag{
&C.StringFlag{Name: "input"},
&C.IntFlag{Name: "count"},
}
for _, flag := range flags {
// Safe extraction with pattern matching
O.MonadFold(
cli.StringFlagPrism().GetOption(flag),
func() { /* Not a string flag */ },
func(sf *C.StringFlag) { /* Handle string flag */ },
)
}
```
### 3. Error Handling
Errors are explicitly represented in the Result type:
```go
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
if err := validateInput(cmd); err != nil {
return R.Left[F.Void](err) // Explicit error
}
return R.Of(F.Void{}) // Success
}
}
}
```
### 4. Testability
Pure functions are easier to test:
```go
func TestCommandEffect(t *testing.T) {
cmd := &C.Command{Name: "test"}
effect := myCommandEffect(cmd)
// Execute effect
result := effect(context.Background())()
// Assert on result
assert.True(t, R.IsRight(result))
}
```
## Migration Guide
### From Standard Actions to Effects
**Before:**
```go
command := &C.Command{
Name: "process",
Action: func(ctx context.Context, cmd *C.Command) error {
input := cmd.String("input")
// Process...
return nil
},
}
```
**After:**
```go
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
input := cmd.String("input")
// Process...
return R.Of(F.Void{})
}
}
}
command := cli.MakeCommand("process", "Process files", flags, effect)
```
### Gradual Migration
You can mix both styles during migration:
```go
// Wrap existing action
existingAction := func(ctx context.Context, cmd *C.Command) error {
// Legacy code...
return nil
}
// Use as Effect
effect := cli.FromAction(existingAction)
command := cli.MakeCommand("legacy", "Legacy command", flags, effect)
```
## See Also
- [Effect Package](../effect/) - Core Effect type definitions
- [Optics Package](../optics/) - Prism and other optics
- [urfave/cli/v3](https://github.com/urfave/cli) - Underlying CLI framework

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -388,8 +387,8 @@ func generateApplyHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -266,8 +265,8 @@ func generateBindHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -189,8 +188,8 @@ func generateDIHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

199
v2/cli/effect.go Normal file
View File

@@ -0,0 +1,199 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"context"
E "github.com/IBM/fp-go/v2/effect"
ET "github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
R "github.com/IBM/fp-go/v2/result"
C "github.com/urfave/cli/v3"
)
// CommandEffect represents a CLI command action as an Effect.
// The Effect takes a *C.Command as context and produces a result.
type CommandEffect = E.Effect[*C.Command, F.Void]
// ToAction converts a CommandEffect into a standard urfave/cli Action function.
// This allows Effect-based command handlers to be used with the cli/v3 framework.
//
// The conversion process:
// 1. Takes the Effect which expects a *C.Command context
// 2. Executes it with the provided command
// 3. Runs the resulting IO operation
// 4. Converts the Result to either nil (success) or error (failure)
//
// # Parameters
//
// - effect: The CommandEffect to convert
//
// # Returns
//
// - A function compatible with C.Command.Action signature
//
// # Example Usage
//
// effect := func(cmd *C.Command) E.Thunk[F.Void] {
// return func(ctx context.Context) E.IOResult[F.Void] {
// return func() R.Result[F.Void] {
// // Command logic here
// return R.Of(F.Void{})
// }
// }
// }
// action := ToAction(effect)
// command := &C.Command{
// Name: "example",
// Action: action,
// }
func ToAction(effect CommandEffect) func(context.Context, *C.Command) error {
return func(ctx context.Context, cmd *C.Command) error {
// Execute the effect: cmd -> ctx -> IO -> Result
return F.Pipe3(
ctx,
effect(cmd),
io.Run,
// Convert Result[Void] to error
ET.Fold(F.Identity[error], F.Constant1[F.Void, error](nil)),
)
}
}
// FromAction converts a standard urfave/cli Action function into a CommandEffect.
// This allows existing cli/v3 action handlers to be lifted into the Effect type.
//
// The conversion process:
// 1. Takes a standard action function (context.Context, *C.Command) -> error
// 2. Wraps it in the Effect structure
// 3. Converts the error result to a Result type
//
// # Parameters
//
// - action: The standard cli/v3 action function to convert
//
// # Returns
//
// - A CommandEffect that wraps the original action
//
// # Example Usage
//
// standardAction := func(ctx context.Context, cmd *C.Command) error {
// // Existing command logic
// return nil
// }
// effect := FromAction(standardAction)
// // Now can be composed with other Effects
func FromAction(action func(context.Context, *C.Command) error) CommandEffect {
return func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
err := action(ctx, cmd)
if err != nil {
return R.Left[F.Void](err)
}
return R.Of(F.Void{})
}
}
}
}
// MakeCommand creates a new Command with an Effect-based action.
// This is a convenience function that combines command creation with Effect conversion.
//
// # Parameters
//
// - name: The command name
// - usage: The command usage description
// - flags: The command flags
// - effect: The CommandEffect to use as the action
//
// # Returns
//
// - A *C.Command configured with the Effect-based action
//
// # Example Usage
//
// cmd := MakeCommand(
// "process",
// "Process data files",
// []C.Flag{
// &C.StringFlag{Name: "input", Usage: "Input file"},
// },
// func(cmd *C.Command) E.Thunk[F.Void] {
// return func(ctx context.Context) E.IOResult[F.Void] {
// return func() R.Result[F.Void] {
// input := cmd.String("input")
// // Process input...
// return R.Of(F.Void{})
// }
// }
// },
// )
func MakeCommand(
name string,
usage string,
flags []C.Flag,
effect CommandEffect,
) *C.Command {
return &C.Command{
Name: name,
Usage: usage,
Flags: flags,
Action: ToAction(effect),
}
}
// MakeCommandWithSubcommands creates a new Command with subcommands and an Effect-based action.
//
// # Parameters
//
// - name: The command name
// - usage: The command usage description
// - flags: The command flags
// - commands: The subcommands
// - effect: The CommandEffect to use as the action
//
// # Returns
//
// - A *C.Command configured with subcommands and the Effect-based action
//
// # Example Usage
//
// cmd := MakeCommandWithSubcommands(
// "app",
// "Application commands",
// []C.Flag{},
// []*C.Command{subCmd1, subCmd2},
// defaultEffect,
// )
func MakeCommandWithSubcommands(
name string,
usage string,
flags []C.Flag,
commands []*C.Command,
effect CommandEffect,
) *C.Command {
return &C.Command{
Name: name,
Usage: usage,
Flags: flags,
Commands: commands,
Action: ToAction(effect),
}
}

204
v2/cli/effect_test.go Normal file
View File

@@ -0,0 +1,204 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"context"
"errors"
"testing"
E "github.com/IBM/fp-go/v2/effect"
F "github.com/IBM/fp-go/v2/function"
R "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
C "github.com/urfave/cli/v3"
)
func TestToAction_Success(t *testing.T) {
t.Run("converts successful Effect to action", func(t *testing.T) {
// Arrange
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
return R.Of(F.Void{})
}
}
}
action := ToAction(effect)
cmd := &C.Command{Name: "test"}
// Act
err := action(context.Background(), cmd)
// Assert
assert.NoError(t, err)
})
}
func TestToAction_Failure(t *testing.T) {
t.Run("converts failed Effect to error", func(t *testing.T) {
// Arrange
expectedErr := errors.New("test error")
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
return R.Left[F.Void](expectedErr)
}
}
}
action := ToAction(effect)
cmd := &C.Command{Name: "test"}
// Act
err := action(context.Background(), cmd)
// Assert
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestFromAction_Success(t *testing.T) {
t.Run("converts successful action to Effect", func(t *testing.T) {
// Arrange
action := func(ctx context.Context, cmd *C.Command) error {
return nil
}
effect := FromAction(action)
cmd := &C.Command{Name: "test"}
// Act
result := effect(cmd)(context.Background())()
// Assert
assert.True(t, R.IsRight(result))
})
}
func TestFromAction_Failure(t *testing.T) {
t.Run("converts failed action to Effect", func(t *testing.T) {
// Arrange
expectedErr := errors.New("test error")
action := func(ctx context.Context, cmd *C.Command) error {
return expectedErr
}
effect := FromAction(action)
cmd := &C.Command{Name: "test"}
// Act
result := effect(cmd)(context.Background())()
// Assert
assert.True(t, R.IsLeft(result))
err := R.MonadFold(result, F.Identity[error], func(F.Void) error { return nil })
assert.Equal(t, expectedErr, err)
})
}
func TestMakeCommand(t *testing.T) {
t.Run("creates command with Effect-based action", func(t *testing.T) {
// Arrange
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
return R.Of(F.Void{})
}
}
}
// Act
cmd := MakeCommand(
"test",
"Test command",
[]C.Flag{},
effect,
)
// Assert
assert.NotNil(t, cmd)
assert.Equal(t, "test", cmd.Name)
assert.Equal(t, "Test command", cmd.Usage)
assert.NotNil(t, cmd.Action)
// Test the action
err := cmd.Action(context.Background(), cmd)
assert.NoError(t, err)
})
}
func TestMakeCommandWithSubcommands(t *testing.T) {
t.Run("creates command with subcommands and Effect-based action", func(t *testing.T) {
// Arrange
subCmd := &C.Command{Name: "sub"}
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
return R.Of(F.Void{})
}
}
}
// Act
cmd := MakeCommandWithSubcommands(
"parent",
"Parent command",
[]C.Flag{},
[]*C.Command{subCmd},
effect,
)
// Assert
assert.NotNil(t, cmd)
assert.Equal(t, "parent", cmd.Name)
assert.Equal(t, "Parent command", cmd.Usage)
assert.Len(t, cmd.Commands, 1)
assert.Equal(t, "sub", cmd.Commands[0].Name)
assert.NotNil(t, cmd.Action)
})
}
func TestToAction_Integration(t *testing.T) {
t.Run("Effect can access command flags", func(t *testing.T) {
// Arrange
var capturedValue string
effect := func(cmd *C.Command) E.Thunk[F.Void] {
return func(ctx context.Context) E.IOResult[F.Void] {
return func() R.Result[F.Void] {
capturedValue = cmd.String("input")
return R.Of(F.Void{})
}
}
}
cmd := &C.Command{
Name: "test",
Flags: []C.Flag{
&C.StringFlag{
Name: "input",
Value: "default-value",
},
},
Action: ToAction(effect),
}
// Act
err := cmd.Action(context.Background(), cmd)
// Assert
assert.NoError(t, err)
assert.Equal(t, "default-value", capturedValue)
})
}

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -148,8 +147,8 @@ func generateEitherHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

359
v2/cli/flags.go Normal file
View File

@@ -0,0 +1,359 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
P "github.com/IBM/fp-go/v2/optics/prism"
O "github.com/IBM/fp-go/v2/option"
C "github.com/urfave/cli/v3"
)
// StringFlagPrism creates a Prism for extracting a StringFlag from a Flag.
// This provides a type-safe way to work with string flags, handling type
// mismatches gracefully through the Option type.
//
// The prism's GetOption attempts to cast a Flag to *C.StringFlag.
// If the cast succeeds, it returns Some(*C.StringFlag); if it fails, it returns None.
//
// The prism's ReverseGet converts a *C.StringFlag back to a Flag.
//
// # Returns
//
// - A Prism[C.Flag, *C.StringFlag] for safe StringFlag extraction
//
// # Example Usage
//
// prism := StringFlagPrism()
//
// // Extract StringFlag from Flag
// var flag C.Flag = &C.StringFlag{Name: "input", Value: "default"}
// result := prism.GetOption(flag) // Some(*C.StringFlag{...})
//
// // Type mismatch returns None
// var intFlag C.Flag = &C.IntFlag{Name: "count"}
// result = prism.GetOption(intFlag) // None[*C.StringFlag]()
//
// // Convert back to Flag
// strFlag := &C.StringFlag{Name: "output"}
// flag = prism.ReverseGet(strFlag)
func StringFlagPrism() P.Prism[C.Flag, *C.StringFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.StringFlag] {
if sf, ok := flag.(*C.StringFlag); ok {
return O.Some(sf)
}
return O.None[*C.StringFlag]()
},
func(f *C.StringFlag) C.Flag { return f },
)
}
// IntFlagPrism creates a Prism for extracting an IntFlag from a Flag.
// This provides a type-safe way to work with integer flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.IntFlag] for safe IntFlag extraction
//
// # Example Usage
//
// prism := IntFlagPrism()
//
// // Extract IntFlag from Flag
// var flag C.Flag = &C.IntFlag{Name: "count", Value: 10}
// result := prism.GetOption(flag) // Some(*C.IntFlag{...})
func IntFlagPrism() P.Prism[C.Flag, *C.IntFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.IntFlag] {
if f, ok := flag.(*C.IntFlag); ok {
return O.Some(f)
}
return O.None[*C.IntFlag]()
},
func(f *C.IntFlag) C.Flag { return f },
)
}
// BoolFlagPrism creates a Prism for extracting a BoolFlag from a Flag.
// This provides a type-safe way to work with boolean flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.BoolFlag] for safe BoolFlag extraction
//
// # Example Usage
//
// prism := BoolFlagPrism()
//
// // Extract BoolFlag from Flag
// var flag C.Flag = &C.BoolFlag{Name: "verbose", Value: true}
// result := prism.GetOption(flag) // Some(*C.BoolFlag{...})
func BoolFlagPrism() P.Prism[C.Flag, *C.BoolFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.BoolFlag] {
if f, ok := flag.(*C.BoolFlag); ok {
return O.Some(f)
}
return O.None[*C.BoolFlag]()
},
func(f *C.BoolFlag) C.Flag { return f },
)
}
// Float64FlagPrism creates a Prism for extracting a Float64Flag from a Flag.
// This provides a type-safe way to work with float64 flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.Float64Flag] for safe Float64Flag extraction
//
// # Example Usage
//
// prism := Float64FlagPrism()
//
// // Extract Float64Flag from Flag
// var flag C.Flag = &C.Float64Flag{Name: "ratio", Value: 0.5}
// result := prism.GetOption(flag) // Some(*C.Float64Flag{...})
func Float64FlagPrism() P.Prism[C.Flag, *C.Float64Flag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.Float64Flag] {
if f, ok := flag.(*C.Float64Flag); ok {
return O.Some(f)
}
return O.None[*C.Float64Flag]()
},
func(f *C.Float64Flag) C.Flag { return f },
)
}
// DurationFlagPrism creates a Prism for extracting a DurationFlag from a Flag.
// This provides a type-safe way to work with duration flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.DurationFlag] for safe DurationFlag extraction
//
// # Example Usage
//
// prism := DurationFlagPrism()
//
// // Extract DurationFlag from Flag
// var flag C.Flag = &C.DurationFlag{Name: "timeout", Value: 30 * time.Second}
// result := prism.GetOption(flag) // Some(*C.DurationFlag{...})
func DurationFlagPrism() P.Prism[C.Flag, *C.DurationFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.DurationFlag] {
if f, ok := flag.(*C.DurationFlag); ok {
return O.Some(f)
}
return O.None[*C.DurationFlag]()
},
func(f *C.DurationFlag) C.Flag { return f },
)
}
// TimestampFlagPrism creates a Prism for extracting a TimestampFlag from a Flag.
// This provides a type-safe way to work with timestamp flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.TimestampFlag] for safe TimestampFlag extraction
//
// # Example Usage
//
// prism := TimestampFlagPrism()
//
// // Extract TimestampFlag from Flag
// var flag C.Flag = &C.TimestampFlag{Name: "created"}
// result := prism.GetOption(flag) // Some(*C.TimestampFlag{...})
func TimestampFlagPrism() P.Prism[C.Flag, *C.TimestampFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.TimestampFlag] {
if f, ok := flag.(*C.TimestampFlag); ok {
return O.Some(f)
}
return O.None[*C.TimestampFlag]()
},
func(f *C.TimestampFlag) C.Flag { return f },
)
}
// StringSliceFlagPrism creates a Prism for extracting a StringSliceFlag from a Flag.
// This provides a type-safe way to work with string slice flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.StringSliceFlag] for safe StringSliceFlag extraction
//
// # Example Usage
//
// prism := StringSliceFlagPrism()
//
// // Extract StringSliceFlag from Flag
// var flag C.Flag = &C.StringSliceFlag{Name: "tags"}
// result := prism.GetOption(flag) // Some(*C.StringSliceFlag{...})
func StringSliceFlagPrism() P.Prism[C.Flag, *C.StringSliceFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.StringSliceFlag] {
if f, ok := flag.(*C.StringSliceFlag); ok {
return O.Some(f)
}
return O.None[*C.StringSliceFlag]()
},
func(f *C.StringSliceFlag) C.Flag { return f },
)
}
// IntSliceFlagPrism creates a Prism for extracting an IntSliceFlag from a Flag.
// This provides a type-safe way to work with int slice flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.IntSliceFlag] for safe IntSliceFlag extraction
//
// # Example Usage
//
// prism := IntSliceFlagPrism()
//
// // Extract IntSliceFlag from Flag
// var flag C.Flag = &C.IntSliceFlag{Name: "ports"}
// result := prism.GetOption(flag) // Some(*C.IntSliceFlag{...})
func IntSliceFlagPrism() P.Prism[C.Flag, *C.IntSliceFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.IntSliceFlag] {
if f, ok := flag.(*C.IntSliceFlag); ok {
return O.Some(f)
}
return O.None[*C.IntSliceFlag]()
},
func(f *C.IntSliceFlag) C.Flag { return f },
)
}
// Float64SliceFlagPrism creates a Prism for extracting a Float64SliceFlag from a Flag.
// This provides a type-safe way to work with float64 slice flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.Float64SliceFlag] for safe Float64SliceFlag extraction
//
// # Example Usage
//
// prism := Float64SliceFlagPrism()
//
// // Extract Float64SliceFlag from Flag
// var flag C.Flag = &C.Float64SliceFlag{Name: "ratios"}
// result := prism.GetOption(flag) // Some(*C.Float64SliceFlag{...})
func Float64SliceFlagPrism() P.Prism[C.Flag, *C.Float64SliceFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.Float64SliceFlag] {
if f, ok := flag.(*C.Float64SliceFlag); ok {
return O.Some(f)
}
return O.None[*C.Float64SliceFlag]()
},
func(f *C.Float64SliceFlag) C.Flag { return f },
)
}
// UintFlagPrism creates a Prism for extracting a UintFlag from a Flag.
// This provides a type-safe way to work with unsigned integer flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.UintFlag] for safe UintFlag extraction
//
// # Example Usage
//
// prism := UintFlagPrism()
//
// // Extract UintFlag from Flag
// var flag C.Flag = &C.UintFlag{Name: "workers", Value: 4}
// result := prism.GetOption(flag) // Some(*C.UintFlag{...})
func UintFlagPrism() P.Prism[C.Flag, *C.UintFlag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.UintFlag] {
if f, ok := flag.(*C.UintFlag); ok {
return O.Some(f)
}
return O.None[*C.UintFlag]()
},
func(f *C.UintFlag) C.Flag { return f },
)
}
// Uint64FlagPrism creates a Prism for extracting a Uint64Flag from a Flag.
// This provides a type-safe way to work with uint64 flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.Uint64Flag] for safe Uint64Flag extraction
//
// # Example Usage
//
// prism := Uint64FlagPrism()
//
// // Extract Uint64Flag from Flag
// var flag C.Flag = &C.Uint64Flag{Name: "size"}
// result := prism.GetOption(flag) // Some(*C.Uint64Flag{...})
func Uint64FlagPrism() P.Prism[C.Flag, *C.Uint64Flag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.Uint64Flag] {
if f, ok := flag.(*C.Uint64Flag); ok {
return O.Some(f)
}
return O.None[*C.Uint64Flag]()
},
func(f *C.Uint64Flag) C.Flag { return f },
)
}
// Int64FlagPrism creates a Prism for extracting an Int64Flag from a Flag.
// This provides a type-safe way to work with int64 flags, handling type
// mismatches gracefully through the Option type.
//
// # Returns
//
// - A Prism[C.Flag, *C.Int64Flag] for safe Int64Flag extraction
//
// # Example Usage
//
// prism := Int64FlagPrism()
//
// // Extract Int64Flag from Flag
// var flag C.Flag = &C.Int64Flag{Name: "offset"}
// result := prism.GetOption(flag) // Some(*C.Int64Flag{...})
func Int64FlagPrism() P.Prism[C.Flag, *C.Int64Flag] {
return P.MakePrism(
func(flag C.Flag) O.Option[*C.Int64Flag] {
if f, ok := flag.(*C.Int64Flag); ok {
return O.Some(f)
}
return O.None[*C.Int64Flag]()
},
func(f *C.Int64Flag) C.Flag { return f },
)
}

287
v2/cli/flags_test.go Normal file
View File

@@ -0,0 +1,287 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"testing"
"time"
O "github.com/IBM/fp-go/v2/option"
"github.com/stretchr/testify/assert"
C "github.com/urfave/cli/v3"
)
func TestStringFlagPrism_Success(t *testing.T) {
t.Run("extracts StringFlag from Flag", func(t *testing.T) {
// Arrange
prism := StringFlagPrism()
var flag C.Flag = &C.StringFlag{Name: "input", Value: "test"}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.StringFlag { return nil }, func(f *C.StringFlag) *C.StringFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "input", extracted.Name)
assert.Equal(t, "test", extracted.Value)
})
}
func TestStringFlagPrism_Failure(t *testing.T) {
t.Run("returns None for non-StringFlag", func(t *testing.T) {
// Arrange
prism := StringFlagPrism()
var flag C.Flag = &C.IntFlag{Name: "count"}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsNone(result))
})
}
func TestStringFlagPrism_ReverseGet(t *testing.T) {
t.Run("converts StringFlag back to Flag", func(t *testing.T) {
// Arrange
prism := StringFlagPrism()
strFlag := &C.StringFlag{Name: "output", Value: "result"}
// Act
flag := prism.ReverseGet(strFlag)
// Assert
assert.NotNil(t, flag)
assert.IsType(t, &C.StringFlag{}, flag)
})
}
func TestIntFlagPrism_Success(t *testing.T) {
t.Run("extracts IntFlag from Flag", func(t *testing.T) {
// Arrange
prism := IntFlagPrism()
var flag C.Flag = &C.IntFlag{Name: "count", Value: 42}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.IntFlag { return nil }, func(f *C.IntFlag) *C.IntFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "count", extracted.Name)
assert.Equal(t, 42, extracted.Value)
})
}
func TestBoolFlagPrism_Success(t *testing.T) {
t.Run("extracts BoolFlag from Flag", func(t *testing.T) {
// Arrange
prism := BoolFlagPrism()
var flag C.Flag = &C.BoolFlag{Name: "verbose", Value: true}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.BoolFlag { return nil }, func(f *C.BoolFlag) *C.BoolFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "verbose", extracted.Name)
assert.Equal(t, true, extracted.Value)
})
}
func TestFloat64FlagPrism_Success(t *testing.T) {
t.Run("extracts Float64Flag from Flag", func(t *testing.T) {
// Arrange
prism := Float64FlagPrism()
var flag C.Flag = &C.Float64Flag{Name: "ratio", Value: 0.5}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.Float64Flag { return nil }, func(f *C.Float64Flag) *C.Float64Flag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "ratio", extracted.Name)
assert.Equal(t, 0.5, extracted.Value)
})
}
func TestDurationFlagPrism_Success(t *testing.T) {
t.Run("extracts DurationFlag from Flag", func(t *testing.T) {
// Arrange
prism := DurationFlagPrism()
duration := 30 * time.Second
var flag C.Flag = &C.DurationFlag{Name: "timeout", Value: duration}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.DurationFlag { return nil }, func(f *C.DurationFlag) *C.DurationFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "timeout", extracted.Name)
assert.Equal(t, duration, extracted.Value)
})
}
func TestTimestampFlagPrism_Success(t *testing.T) {
t.Run("extracts TimestampFlag from Flag", func(t *testing.T) {
// Arrange
prism := TimestampFlagPrism()
var flag C.Flag = &C.TimestampFlag{Name: "created"}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.TimestampFlag { return nil }, func(f *C.TimestampFlag) *C.TimestampFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "created", extracted.Name)
})
}
func TestStringSliceFlagPrism_Success(t *testing.T) {
t.Run("extracts StringSliceFlag from Flag", func(t *testing.T) {
// Arrange
prism := StringSliceFlagPrism()
var flag C.Flag = &C.StringSliceFlag{Name: "tags"}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.StringSliceFlag { return nil }, func(f *C.StringSliceFlag) *C.StringSliceFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "tags", extracted.Name)
})
}
func TestIntSliceFlagPrism_Success(t *testing.T) {
t.Run("extracts IntSliceFlag from Flag", func(t *testing.T) {
// Arrange
prism := IntSliceFlagPrism()
var flag C.Flag = &C.IntSliceFlag{Name: "ports"}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.IntSliceFlag { return nil }, func(f *C.IntSliceFlag) *C.IntSliceFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "ports", extracted.Name)
})
}
func TestFloat64SliceFlagPrism_Success(t *testing.T) {
t.Run("extracts Float64SliceFlag from Flag", func(t *testing.T) {
// Arrange
prism := Float64SliceFlagPrism()
var flag C.Flag = &C.Float64SliceFlag{Name: "ratios"}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.Float64SliceFlag { return nil }, func(f *C.Float64SliceFlag) *C.Float64SliceFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "ratios", extracted.Name)
})
}
func TestUintFlagPrism_Success(t *testing.T) {
t.Run("extracts UintFlag from Flag", func(t *testing.T) {
// Arrange
prism := UintFlagPrism()
var flag C.Flag = &C.UintFlag{Name: "workers", Value: 4}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.UintFlag { return nil }, func(f *C.UintFlag) *C.UintFlag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "workers", extracted.Name)
assert.Equal(t, uint(4), extracted.Value)
})
}
func TestUint64FlagPrism_Success(t *testing.T) {
t.Run("extracts Uint64Flag from Flag", func(t *testing.T) {
// Arrange
prism := Uint64FlagPrism()
var flag C.Flag = &C.Uint64Flag{Name: "size", Value: 1024}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.Uint64Flag { return nil }, func(f *C.Uint64Flag) *C.Uint64Flag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "size", extracted.Name)
assert.Equal(t, uint64(1024), extracted.Value)
})
}
func TestInt64FlagPrism_Success(t *testing.T) {
t.Run("extracts Int64Flag from Flag", func(t *testing.T) {
// Arrange
prism := Int64FlagPrism()
var flag C.Flag = &C.Int64Flag{Name: "offset", Value: -100}
// Act
result := prism.GetOption(flag)
// Assert
assert.True(t, O.IsSome(result))
extracted := O.MonadFold(result, func() *C.Int64Flag { return nil }, func(f *C.Int64Flag) *C.Int64Flag { return f })
assert.NotNil(t, extracted)
assert.Equal(t, "offset", extracted.Name)
assert.Equal(t, int64(-100), extracted.Value)
})
}
func TestPrisms_EdgeCases(t *testing.T) {
t.Run("all prisms return None for wrong type", func(t *testing.T) {
// Arrange
var flag C.Flag = &C.StringFlag{Name: "test"}
// Act & Assert
assert.True(t, O.IsNone(IntFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(BoolFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(Float64FlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(DurationFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(TimestampFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(StringSliceFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(IntSliceFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(Float64SliceFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(UintFlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(Uint64FlagPrism().GetOption(flag)))
assert.True(t, O.IsNone(Int64FlagPrism().GetOption(flag)))
})
}

View File

@@ -18,7 +18,6 @@ package cli
import (
"fmt"
"os"
"time"
)
func writePackage(f *os.File, pkg string) {
@@ -26,6 +25,6 @@ func writePackage(f *os.File, pkg string) {
fmt.Fprintf(f, "package %s\n\n", pkg)
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
}

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -62,8 +61,8 @@ func generateIdentityHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
A "github.com/IBM/fp-go/v2/array"
C "github.com/urfave/cli/v3"
@@ -71,8 +70,8 @@ func generateIOHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
A "github.com/IBM/fp-go/v2/array"
C "github.com/urfave/cli/v3"
@@ -219,8 +218,8 @@ func generateIOEitherHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)
@@ -234,8 +233,7 @@ import (
// some header
fmt.Fprintln(fg, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(fg, "// This file was generated by robots at")
fmt.Fprintf(fg, "// %s\n", time.Now())
fmt.Fprintln(fg, "// This file was generated by robots.")
fmt.Fprintf(fg, "package generic\n\n")

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
A "github.com/IBM/fp-go/v2/array"
C "github.com/urfave/cli/v3"
@@ -76,8 +75,8 @@ func generateIOOptionHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -148,8 +147,8 @@ func generateOptionHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -378,8 +377,8 @@ func generatePipeHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n", pkg)

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -118,8 +117,8 @@ func generateReaderHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)
@@ -131,8 +130,7 @@ import (
// some header
fmt.Fprintln(fg, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(fg, "// This file was generated by robots at")
fmt.Fprintf(fg, "// %s\n", time.Now())
fmt.Fprintln(fg, "// This file was generated by robots.")
fmt.Fprintf(fg, "package generic\n\n")

View File

@@ -21,7 +21,6 @@ import (
"log"
"os"
"path/filepath"
"time"
C "github.com/urfave/cli/v3"
)
@@ -233,8 +232,8 @@ func generateReaderIOEitherHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)
@@ -246,8 +245,7 @@ import (
// some header
fmt.Fprintln(fg, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(fg, "// This file was generated by robots at")
fmt.Fprintf(fg, "// %s\n", time.Now())
fmt.Fprintln(fg, "// This file was generated by robots.")
fmt.Fprintf(fg, "package generic\n\n")

View File

@@ -22,7 +22,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
C "github.com/urfave/cli/v3"
)
@@ -399,8 +398,8 @@ func generateTupleHelpers(filename string, count int) error {
// some header
fmt.Fprintln(f, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintln(f, "// This file was generated by robots at")
fmt.Fprintf(f, "// %s\n\n", time.Now())
fmt.Fprintln(f, "// This file was generated by robots.")
fmt.Fprintln(f)
fmt.Fprintf(f, "package %s\n\n", pkg)

View File

@@ -13,6 +13,37 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package constant provides the Const functor, a phantom type that ignores its second type parameter.
//
// The Const functor is a fundamental building block in functional programming that wraps a value
// of type E while having a phantom type parameter A. This makes it useful for:
// - Accumulating values during traversals (e.g., collecting metadata)
// - Implementing optics (lenses, prisms) where you need to track information
// - Building applicative functors that combine values using a semigroup
//
// # The Const Functor
//
// Const[E, A] wraps a value of type E and has a phantom type parameter A that doesn't affect
// the runtime value. This allows it to participate in functor and applicative operations while
// maintaining the wrapped value unchanged.
//
// # Key Properties
//
// - Map operations ignore the function and preserve the wrapped value
// - Ap operations combine wrapped values using a semigroup
// - The phantom type A allows type-safe composition with other functors
//
// # Example Usage
//
// // Accumulate string values
// c1 := Make[string, int]("hello")
// c2 := Make[string, int]("world")
//
// // Map doesn't change the wrapped value
// mapped := Map[string, int, string](strconv.Itoa)(c1) // Still contains "hello"
//
// // Ap combines values using a semigroup
// combined := Ap[string, int, int](S.Monoid)(c1)(c2) // Contains "helloworld"
package constant
import (
@@ -21,36 +52,209 @@ import (
S "github.com/IBM/fp-go/v2/semigroup"
)
// Const is a functor that wraps a value of type E with a phantom type parameter A.
//
// The Const functor is useful for accumulating values during traversals or implementing
// optics. The type parameter A is phantom - it doesn't affect the runtime value but allows
// the type to participate in functor and applicative operations.
//
// Type Parameters:
// - E: The type of the wrapped value (the actual data)
// - A: The phantom type parameter (not stored, only used for type-level operations)
//
// Example:
//
// // Create a Const that wraps a string
// c := Make[string, int]("metadata")
//
// // The int type parameter is phantom - no int value is stored
// value := Unwrap(c) // "metadata"
type Const[E, A any] struct {
value E
}
// Make creates a Const value wrapping the given value.
//
// This is the primary constructor for Const values. The second type parameter A
// is phantom and must be specified explicitly when needed for type inference.
//
// Type Parameters:
// - E: The type of the value to wrap
// - A: The phantom type parameter
//
// Parameters:
// - e: The value to wrap
//
// Returns:
// - A Const[E, A] wrapping the value
//
// Example:
//
// c := Make[string, int]("hello")
// value := Unwrap(c) // "hello"
func Make[E, A any](e E) Const[E, A] {
return Const[E, A]{value: e}
}
// Unwrap extracts the wrapped value from a Const.
//
// This is the inverse of Make, retrieving the actual value stored in the Const.
//
// Type Parameters:
// - E: The type of the wrapped value
// - A: The phantom type parameter
//
// Parameters:
// - c: The Const to unwrap
//
// Returns:
// - The wrapped value of type E
//
// Example:
//
// c := Make[string, int]("world")
// value := Unwrap(c) // "world"
func Unwrap[E, A any](c Const[E, A]) E {
return c.value
}
// Of creates a Const containing the monoid's empty value, ignoring the input.
//
// This implements the Applicative's "pure" operation for Const. It creates a Const
// wrapping the monoid's identity element, regardless of the input value.
//
// Type Parameters:
// - E: The type of the wrapped value (must have a monoid)
// - A: The input type (ignored)
//
// Parameters:
// - m: The monoid providing the empty value
//
// Returns:
// - A function that ignores its input and returns Const[E, A] with the empty value
//
// Example:
//
// import S "github.com/IBM/fp-go/v2/string"
//
// of := Of[string, int](S.Monoid)
// c := of(42) // Const[string, int] containing ""
// value := Unwrap(c) // ""
func Of[E, A any](m M.Monoid[E]) func(A) Const[E, A] {
return F.Constant1[A](Make[E, A](m.Empty()))
}
// MonadMap applies a function to the phantom type parameter without changing the wrapped value.
//
// This implements the Functor's map operation for Const. Since the type parameter A is phantom,
// the function is never actually called - the wrapped value E remains unchanged.
//
// Type Parameters:
// - E: The type of the wrapped value
// - A: The input phantom type
// - B: The output phantom type
//
// Parameters:
// - fa: The Const to map over
// - _: The function to apply (ignored)
//
// Returns:
// - A Const[E, B] with the same wrapped value
//
// Example:
//
// c := Make[string, int]("hello")
// mapped := MonadMap(c, func(i int) string { return strconv.Itoa(i) })
// // mapped still contains "hello", function was never called
func MonadMap[E, A, B any](fa Const[E, A], _ func(A) B) Const[E, B] {
return Make[E, B](fa.value)
}
// MonadAp combines two Const values using a semigroup.
//
// This implements the Applicative's ap operation for Const. It combines the wrapped
// values from both Const instances using the provided semigroup, ignoring the function
// type in the first argument.
//
// Type Parameters:
// - E: The type of the wrapped values (must have a semigroup)
// - A: The input phantom type
// - B: The output phantom type
//
// Parameters:
// - s: The semigroup for combining wrapped values
//
// Returns:
// - A function that takes two Const values and combines their wrapped values
//
// Example:
//
// import S "github.com/IBM/fp-go/v2/string"
//
// ap := MonadAp[string, int, int](S.Monoid)
// c1 := Make[string, func(int) int]("hello")
// c2 := Make[string, int]("world")
// result := ap(c1, c2) // Const containing "helloworld"
func MonadAp[E, A, B any](s S.Semigroup[E]) func(fab Const[E, func(A) B], fa Const[E, A]) Const[E, B] {
return func(fab Const[E, func(A) B], fa Const[E, A]) Const[E, B] {
return Make[E, B](s.Concat(fab.value, fa.value))
}
}
// Map applies a function to the phantom type parameter without changing the wrapped value.
//
// This is the curried version of MonadMap, providing a more functional programming style.
// The function is never actually called since A is a phantom type.
//
// Type Parameters:
// - E: The type of the wrapped value
// - A: The input phantom type
// - B: The output phantom type
//
// Parameters:
// - f: The function to apply (ignored)
//
// Returns:
// - A function that transforms Const[E, A] to Const[E, B]
//
// Example:
//
// import F "github.com/IBM/fp-go/v2/function"
//
// c := Make[string, int]("data")
// mapped := F.Pipe1(c, Map[string, int, string](strconv.Itoa))
// // mapped still contains "data"
func Map[E, A, B any](f func(A) B) func(fa Const[E, A]) Const[E, B] {
return F.Bind2nd(MonadMap[E, A, B], f)
}
// Ap combines Const values using a semigroup in a curried style.
//
// This is the curried version of MonadAp, providing data-last style for better composition.
// It combines the wrapped values from both Const instances using the provided semigroup.
//
// Type Parameters:
// - E: The type of the wrapped values (must have a semigroup)
// - A: The input phantom type
// - B: The output phantom type
//
// Parameters:
// - s: The semigroup for combining wrapped values
//
// Returns:
// - A curried function for combining Const values
//
// Example:
//
// import (
// F "github.com/IBM/fp-go/v2/function"
// S "github.com/IBM/fp-go/v2/string"
// )
//
// c1 := Make[string, int]("hello")
// c2 := Make[string, func(int) int]("world")
// result := F.Pipe1(c1, Ap[string, int, int](S.Monoid)(c2))
// // result contains "helloworld"
func Ap[E, A, B any](s S.Semigroup[E]) func(fa Const[E, A]) func(fab Const[E, func(A) B]) Const[E, B] {
monadap := MonadAp[E, A, B](s)
return func(fa Const[E, A]) func(fab Const[E, func(A) B]) Const[E, B] {

View File

@@ -16,25 +16,340 @@
package constant
import (
"strconv"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/utils"
N "github.com/IBM/fp-go/v2/number"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
)
func TestMap(t *testing.T) {
fa := Make[string, int]("foo")
assert.Equal(t, fa, F.Pipe1(fa, Map[string](utils.Double)))
// TestMake tests the Make constructor
func TestMake(t *testing.T) {
t.Run("creates Const with string value", func(t *testing.T) {
c := Make[string, int]("hello")
assert.Equal(t, "hello", Unwrap(c))
})
t.Run("creates Const with int value", func(t *testing.T) {
c := Make[int, string](42)
assert.Equal(t, 42, Unwrap(c))
})
t.Run("creates Const with struct value", func(t *testing.T) {
type Config struct {
Name string
Port int
}
cfg := Config{Name: "server", Port: 8080}
c := Make[Config, bool](cfg)
assert.Equal(t, cfg, Unwrap(c))
})
}
// TestUnwrap tests extracting values from Const
func TestUnwrap(t *testing.T) {
t.Run("unwraps string value", func(t *testing.T) {
c := Make[string, int]("world")
value := Unwrap(c)
assert.Equal(t, "world", value)
})
t.Run("unwraps empty string", func(t *testing.T) {
c := Make[string, int]("")
value := Unwrap(c)
assert.Equal(t, "", value)
})
t.Run("unwraps zero value", func(t *testing.T) {
c := Make[int, string](0)
value := Unwrap(c)
assert.Equal(t, 0, value)
})
}
// TestOf tests the Of function
func TestOf(t *testing.T) {
assert.Equal(t, Make[string, int](""), Of[string, int](S.Monoid)(1))
t.Run("creates Const with monoid empty value", func(t *testing.T) {
of := Of[string, int](S.Monoid)
c := of(42)
assert.Equal(t, "", Unwrap(c))
})
t.Run("ignores input value", func(t *testing.T) {
of := Of[string, int](S.Monoid)
c1 := of(1)
c2 := of(100)
assert.Equal(t, Unwrap(c1), Unwrap(c2))
})
t.Run("works with int monoid", func(t *testing.T) {
of := Of[int, string](N.MonoidSum[int]())
c := of("ignored")
assert.Equal(t, 0, Unwrap(c))
})
}
func TestAp(t *testing.T) {
fab := Make[string, int]("bar")
assert.Equal(t, Make[string, int]("foobar"), Ap[string, int, int](S.Monoid)(fab)(Make[string, func(int) int]("foo")))
// TestMap tests the Map function
func TestMap(t *testing.T) {
t.Run("preserves wrapped value", func(t *testing.T) {
fa := Make[string, int]("foo")
result := F.Pipe1(fa, Map[string](utils.Double))
assert.Equal(t, "foo", Unwrap(result))
})
t.Run("changes phantom type", func(t *testing.T) {
fa := Make[string, int]("data")
fb := Map[string, int, string](strconv.Itoa)(fa)
// Value unchanged, but type changed from Const[string, int] to Const[string, string]
assert.Equal(t, "data", Unwrap(fb))
})
t.Run("function is never called", func(t *testing.T) {
called := false
fa := Make[string, int]("test")
fb := Map[string, int, string](func(i int) string {
called = true
return strconv.Itoa(i)
})(fa)
assert.False(t, called, "Map function should not be called")
assert.Equal(t, "test", Unwrap(fb))
})
}
// TestMonadMap tests the MonadMap function
func TestMonadMap(t *testing.T) {
t.Run("preserves wrapped value", func(t *testing.T) {
fa := Make[string, int]("original")
fb := MonadMap(fa, func(i int) string { return strconv.Itoa(i) })
assert.Equal(t, "original", Unwrap(fb))
})
t.Run("works with different types", func(t *testing.T) {
fa := Make[int, string](42)
fb := MonadMap(fa, func(s string) bool { return len(s) > 0 })
assert.Equal(t, 42, Unwrap(fb))
})
}
// TestAp tests the Ap function
func TestAp(t *testing.T) {
t.Run("combines string values", func(t *testing.T) {
fab := Make[string, int]("bar")
fa := Make[string, func(int) int]("foo")
result := Ap[string, int, int](S.Monoid)(fab)(fa)
assert.Equal(t, "foobar", Unwrap(result))
})
t.Run("combines int values with sum", func(t *testing.T) {
fab := Make[int, string](10)
fa := Make[int, func(string) string](5)
result := Ap[int, string, string](N.SemigroupSum[int]())(fab)(fa)
assert.Equal(t, 15, Unwrap(result))
})
t.Run("combines int values with product", func(t *testing.T) {
fab := Make[int, bool](3)
fa := Make[int, func(bool) bool](4)
result := Ap[int, bool, bool](N.SemigroupProduct[int]())(fab)(fa)
assert.Equal(t, 12, Unwrap(result))
})
}
// TestMonadAp tests the MonadAp function
func TestMonadAp(t *testing.T) {
t.Run("combines values using semigroup", func(t *testing.T) {
ap := MonadAp[string, int, int](S.Monoid)
fab := Make[string, func(int) int]("hello")
fa := Make[string, int]("world")
result := ap(fab, fa)
assert.Equal(t, "helloworld", Unwrap(result))
})
t.Run("works with empty strings", func(t *testing.T) {
ap := MonadAp[string, int, int](S.Monoid)
fab := Make[string, func(int) int]("")
fa := Make[string, int]("test")
result := ap(fab, fa)
assert.Equal(t, "test", Unwrap(result))
})
}
// TestMonoid tests the Monoid function
func TestMonoid(t *testing.T) {
t.Run("always returns constant value", func(t *testing.T) {
m := Monoid(42)
assert.Equal(t, 42, m.Concat(1, 2))
assert.Equal(t, 42, m.Concat(100, 200))
assert.Equal(t, 42, m.Empty())
})
t.Run("works with strings", func(t *testing.T) {
m := Monoid("constant")
assert.Equal(t, "constant", m.Concat("a", "b"))
assert.Equal(t, "constant", m.Empty())
})
t.Run("works with structs", func(t *testing.T) {
type Point struct{ X, Y int }
p := Point{X: 1, Y: 2}
m := Monoid(p)
assert.Equal(t, p, m.Concat(Point{X: 3, Y: 4}, Point{X: 5, Y: 6}))
assert.Equal(t, p, m.Empty())
})
t.Run("satisfies monoid laws", func(t *testing.T) {
m := Monoid(10)
// Left identity: Concat(Empty(), x) = x (both return constant)
assert.Equal(t, 10, m.Concat(m.Empty(), 5))
// Right identity: Concat(x, Empty()) = x (both return constant)
assert.Equal(t, 10, m.Concat(5, m.Empty()))
// Associativity: Concat(Concat(x, y), z) = Concat(x, Concat(y, z))
left := m.Concat(m.Concat(1, 2), 3)
right := m.Concat(1, m.Concat(2, 3))
assert.Equal(t, left, right)
assert.Equal(t, 10, left)
})
}
// TestConstFunctorLaws tests functor laws for Const
func TestConstFunctorLaws(t *testing.T) {
t.Run("identity law", func(t *testing.T) {
// map id = id
fa := Make[string, int]("test")
mapped := Map[string, int, int](F.Identity[int])(fa)
assert.Equal(t, Unwrap(fa), Unwrap(mapped))
})
t.Run("composition law", func(t *testing.T) {
// map (g . f) = map g . map f
fa := Make[string, int]("data")
f := func(i int) string { return strconv.Itoa(i) }
g := func(s string) bool { return len(s) > 0 }
// map (g . f)
composed := Map[string, int, bool](func(i int) bool { return g(f(i)) })(fa)
// map g . map f
intermediate := F.Pipe1(fa, Map[string, int, string](f))
chained := Map[string, string, bool](g)(intermediate)
assert.Equal(t, Unwrap(composed), Unwrap(chained))
})
}
// TestConstApplicativeLaws tests applicative laws for Const
func TestConstApplicativeLaws(t *testing.T) {
t.Run("identity law", func(t *testing.T) {
// For Const, ap combines the wrapped values using the semigroup
// ap (of id) v combines empty (from of) with v's value
v := Make[string, int]("value")
ofId := Of[string, func(int) int](S.Monoid)(F.Identity[int])
result := Ap[string, int, int](S.Monoid)(v)(ofId)
// Result combines "" (from Of) with "value" using string monoid
assert.Equal(t, "value", Unwrap(result))
})
t.Run("homomorphism law", func(t *testing.T) {
// ap (of f) (of x) = of (f x)
f := func(i int) string { return strconv.Itoa(i) }
x := 42
ofF := Of[string, func(int) string](S.Monoid)(f)
ofX := Of[string, int](S.Monoid)(x)
left := Ap[string, int, string](S.Monoid)(ofX)(ofF)
right := Of[string, string](S.Monoid)(f(x))
assert.Equal(t, Unwrap(left), Unwrap(right))
})
}
// TestConstEdgeCases tests edge cases
func TestConstEdgeCases(t *testing.T) {
t.Run("empty string values", func(t *testing.T) {
c := Make[string, int]("")
assert.Equal(t, "", Unwrap(c))
mapped := Map[string, int, string](strconv.Itoa)(c)
assert.Equal(t, "", Unwrap(mapped))
})
t.Run("zero values", func(t *testing.T) {
c := Make[int, string](0)
assert.Equal(t, 0, Unwrap(c))
})
t.Run("nil pointer", func(t *testing.T) {
var ptr *int
c := Make[*int, string](ptr)
assert.Nil(t, Unwrap(c))
})
t.Run("multiple map operations", func(t *testing.T) {
c := Make[string, int]("original")
// Chain multiple map operations
step1 := Map[string, int, string](strconv.Itoa)(c)
step2 := Map[string, string, bool](func(s string) bool { return len(s) > 0 })(step1)
result := Map[string, bool, int](func(b bool) int {
if b {
return 1
}
return 0
})(step2)
assert.Equal(t, "original", Unwrap(result))
})
}
// BenchmarkMake benchmarks the Make constructor
func BenchmarkMake(b *testing.B) {
b.ResetTimer()
for b.Loop() {
_ = Make[string, int]("test")
}
}
// BenchmarkUnwrap benchmarks the Unwrap function
func BenchmarkUnwrap(b *testing.B) {
c := Make[string, int]("test")
b.ResetTimer()
for b.Loop() {
_ = Unwrap(c)
}
}
// BenchmarkMap benchmarks the Map function
func BenchmarkMap(b *testing.B) {
c := Make[string, int]("test")
mapFn := Map[string, int, string](strconv.Itoa)
b.ResetTimer()
for b.Loop() {
_ = mapFn(c)
}
}
// BenchmarkAp benchmarks the Ap function
func BenchmarkAp(b *testing.B) {
fab := Make[string, int]("hello")
fa := Make[string, func(int) int]("world")
apFn := Ap[string, int, int](S.Monoid)
b.ResetTimer()
for b.Loop() {
_ = apFn(fab)(fa)
}
}
// BenchmarkMonoid benchmarks the Monoid function
func BenchmarkMonoid(b *testing.B) {
m := Monoid(42)
b.ResetTimer()
for b.Loop() {
_ = m.Concat(1, 2)
}
}

View File

@@ -1,3 +1,18 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package constant
import (
@@ -5,7 +20,47 @@ import (
M "github.com/IBM/fp-go/v2/monoid"
)
// Monoid returns a [M.Monoid] that returns a constant value in all operations
// Monoid creates a monoid that always returns a constant value.
//
// This creates a trivial monoid where both the Concat operation and Empty
// always return the same constant value, regardless of inputs. This is useful
// for testing, placeholder implementations, or when you need a monoid instance
// but the actual combining behavior doesn't matter.
//
// # Monoid Laws
//
// The constant monoid satisfies all monoid laws trivially:
// - Associativity: Concat(Concat(x, y), z) = Concat(x, Concat(y, z)) - always returns 'a'
// - Left Identity: Concat(Empty(), x) = x - both return 'a'
// - Right Identity: Concat(x, Empty()) = x - both return 'a'
//
// Type Parameters:
// - A: The type of the constant value
//
// Parameters:
// - a: The constant value to return in all operations
//
// Returns:
// - A Monoid[A] that always returns the constant value
//
// Example:
//
// // Create a monoid that always returns 42
// m := Monoid(42)
// result := m.Concat(1, 2) // 42
// empty := m.Empty() // 42
//
// // Useful for testing or placeholder implementations
// type Config struct {
// Timeout int
// }
// defaultConfig := Monoid(Config{Timeout: 30})
// config := defaultConfig.Concat(Config{Timeout: 10}, Config{Timeout: 20})
// // config is Config{Timeout: 30}
//
// See also:
// - function.Constant2: The underlying constant function
// - M.MakeMonoid: The monoid constructor
func Monoid[A any](a A) M.Monoid[A] {
return M.MakeMonoid(function.Constant2[A, A](a), a)
}

View File

@@ -13,28 +13,218 @@
// See the License for the specific language governing permissions and
// limitations under the License.
/*
Package constraints defines a set of useful type constraints for generic programming in Go.
# Overview
This package provides type constraints that can be used with Go generics to restrict
type parameters to specific categories of types. These constraints are similar to those
in Go's standard constraints package but are defined here for consistency within the
fp-go project.
# Type Constraints
Ordered - Types that support comparison operators:
type Ordered interface {
Integer | Float | ~string
}
Used for types that can be compared using <, <=, >, >= operators.
Integer - All integer types (signed and unsigned):
type Integer interface {
Signed | Unsigned
}
Signed - Signed integer types:
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
Unsigned - Unsigned integer types:
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
Float - Floating-point types:
type Float interface {
~float32 | ~float64
}
Complex - Complex number types:
type Complex interface {
~complex64 | ~complex128
}
# Usage Examples
Using Ordered constraint for comparison:
import C "github.com/IBM/fp-go/v2/constraints"
func Min[T C.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
result := Min(5, 3) // 3
result := Min(3.14, 2.71) // 2.71
result := Min("apple", "banana") // "apple"
Using Integer constraint:
func Abs[T C.Integer](n T) T {
if n < 0 {
return -n
}
return n
}
result := Abs(-42) // 42
result := Abs(uint(10)) // 10
Using Float constraint:
func Average[T C.Float](a, b T) T {
return (a + b) / 2
}
result := Average(3.14, 2.86) // 3.0
Using Complex constraint:
func Magnitude[T C.Complex](c T) float64 {
r, i := real(c), imag(c)
return math.Sqrt(r*r + i*i)
}
c := complex(3, 4)
result := Magnitude(c) // 5.0
# Combining Constraints
Constraints can be combined to create more specific type restrictions:
type Number interface {
C.Integer | C.Float | C.Complex
}
func Add[T Number](a, b T) T {
return a + b
}
# Tilde Operator
The ~ operator in type constraints means "underlying type". For example, ~int
matches not only int but also any type whose underlying type is int:
type MyInt int
func Double[T C.Integer](n T) T {
return n * 2
}
var x MyInt = 5
result := Double(x) // Works because MyInt's underlying type is int
# Related Packages
- number: Provides algebraic structures and utilities for numeric types
- ord: Provides ordering operations using these constraints
- eq: Provides equality operations for comparable types
*/
package constraints
// Ordered is a constraint that permits any ordered type: any type that supports
// the operators < <= >= >. Ordered types include integers, floats, and strings.
//
// This constraint is commonly used for comparison operations, sorting, and
// finding minimum/maximum values.
//
// Example:
//
// func Max[T Ordered](a, b T) T {
// if a > b {
// return a
// }
// return b
// }
type Ordered interface {
Integer | Float | ~string
}
// Signed is a constraint that permits any signed integer type.
// This includes int, int8, int16, int32, and int64, as well as any
// types whose underlying type is one of these.
//
// Example:
//
// func Negate[T Signed](n T) T {
// return -n
// }
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// Unsigned is a constraint that permits any unsigned integer type.
// This includes uint, uint8, uint16, uint32, uint64, and uintptr, as well
// as any types whose underlying type is one of these.
//
// Example:
//
// func IsEven[T Unsigned](n T) bool {
// return n%2 == 0
// }
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// Integer is a constraint that permits any integer type, both signed and unsigned.
// This is a union of the Signed and Unsigned constraints.
//
// Example:
//
// func Abs[T Integer](n T) T {
// if n < 0 {
// return -n
// }
// return n
// }
type Integer interface {
Signed | Unsigned
}
// Float is a constraint that permits any floating-point type.
// This includes float32 and float64, as well as any types whose
// underlying type is one of these.
//
// Example:
//
// func Round[T Float](f T) T {
// return T(math.Round(float64(f)))
// }
type Float interface {
~float32 | ~float64
}
// Complex is a constraint that permits any complex numeric type.
// This includes complex64 and complex128, as well as any types whose
// underlying type is one of these.
//
// Example:
//
// func Conjugate[T Complex](c T) T {
// return complex(real(c), -imag(c))
// }
type Complex interface {
~complex64 | ~complex128
}

130
v2/context/reader/reader.go Normal file
View File

@@ -0,0 +1,130 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package reader provides a specialization of the Reader monad for [context.Context].
//
// This package offers a context-aware Reader monad that simplifies working with
// Go's [context.Context] in a functional programming style. It eliminates the need
// to explicitly thread context through function calls while maintaining type safety
// and composability.
//
// # Core Concept
//
// The Reader monad represents computations that depend on a shared environment.
// In this package, that environment is fixed to [context.Context], making it
// particularly useful for:
//
// - Request-scoped data propagation
// - Cancellation and timeout handling
// - Dependency injection via context values
// - Avoiding explicit context parameter threading
//
// # Type Definitions
//
// - Reader[A]: A computation that depends on context.Context and produces A
// - Kleisli[A, B]: A function from A to Reader[B] for composing computations
// - Operator[A, B]: A transformation from Reader[A] to Reader[B]
//
// # Usage Pattern
//
// Instead of passing context explicitly through every function:
//
// func processUser(ctx context.Context, userID string) (User, error) {
// user := fetchUser(ctx, userID)
// profile := fetchProfile(ctx, user.ProfileID)
// return enrichUser(ctx, user, profile), nil
// }
//
// You can use Reader to compose context-dependent operations:
//
// fetchUser := func(userID string) Reader[User] {
// return func(ctx context.Context) User {
// // Use ctx for database access, cancellation, etc.
// return queryDatabase(ctx, userID)
// }
// }
//
// processUser := func(userID string) Reader[User] {
// return F.Pipe2(
// fetchUser(userID),
// reader.Chain(func(user User) Reader[Profile] {
// return fetchProfile(user.ProfileID)
// }),
// reader.Map(func(profile Profile) User {
// return enrichUser(user, profile)
// }),
// )
// }
//
// // Execute with context
// ctx := context.Background()
// user := processUser("user123")(ctx)
//
// # Integration with Standard Library
//
// This package works seamlessly with Go's standard [context] package:
//
// - Context cancellation and deadlines are preserved
// - Context values can be accessed within Reader computations
// - Readers can be composed with context-aware libraries
//
// # Relationship to Other Packages
//
// This package is a specialization of [github.com/IBM/fp-go/v2/reader] where
// the environment type R is fixed to [context.Context]. For more general
// Reader operations, see the base reader package.
//
// For combining Reader with other monads:
// - [github.com/IBM/fp-go/v2/context/readerio]: Reader + IO effects
// - [github.com/IBM/fp-go/v2/readeroption]: Reader + Option
// - [github.com/IBM/fp-go/v2/readerresult]: Reader + Result (Either)
//
// # Example: HTTP Request Handler
//
// type RequestContext struct {
// UserID string
// RequestID string
// }
//
// // Extract request context from context.Context
// getRequestContext := func(ctx context.Context) RequestContext {
// return RequestContext{
// UserID: ctx.Value("userID").(string),
// RequestID: ctx.Value("requestID").(string),
// }
// }
//
// // A Reader that logs with request context
// logInfo := func(message string) Reader[function.Void] {
// return func(ctx context.Context) function.Void {
// reqCtx := getRequestContext(ctx)
// log.Printf("[%s] User %s: %s", reqCtx.RequestID, reqCtx.UserID, message)
// return function.VOID
// }
// }
//
// // Compose operations
// handleRequest := func(data string) Reader[Response] {
// return F.Pipe2(
// logInfo("Processing request"),
// reader.Chain(func(_ function.Void) Reader[Result] {
// return processData(data)
// }),
// reader.Map(func(result Result) Response {
// return Response{Data: result}
// }),
// )
// }
package reader

142
v2/context/reader/types.go Normal file
View File

@@ -0,0 +1,142 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package reader
import (
"context"
R "github.com/IBM/fp-go/v2/reader"
)
type (
// Reader represents a computation that depends on a [context.Context] and produces a value of type A.
//
// This is a specialization of the generic Reader monad where the environment type is fixed
// to [context.Context]. This is particularly useful for Go applications that need to thread
// context through computations for cancellation, deadlines, and request-scoped values.
//
// Type Parameters:
// - A: The result type produced by the computation
//
// Reader[A] is equivalent to func(context.Context) A
//
// The Reader monad enables:
// - Dependency injection using context values
// - Cancellation and timeout handling
// - Request-scoped data propagation
// - Avoiding explicit context parameter threading
//
// Example:
//
// // A Reader that extracts a user ID from context
// getUserID := func(ctx context.Context) string {
// if userID, ok := ctx.Value("userID").(string); ok {
// return userID
// }
// return "anonymous"
// }
//
// // A Reader that checks if context is cancelled
// isCancelled := func(ctx context.Context) bool {
// select {
// case <-ctx.Done():
// return true
// default:
// return false
// }
// }
//
// // Use the readers with a context
// ctx := context.WithValue(context.Background(), "userID", "user123")
// userID := getUserID(ctx) // "user123"
// cancelled := isCancelled(ctx) // false
Reader[A any] = R.Reader[context.Context, A]
// Kleisli represents a Kleisli arrow for the context-based Reader monad.
//
// It's a function from A to Reader[B], used for composing Reader computations
// that all depend on the same [context.Context].
//
// Type Parameters:
// - A: The input type
// - B: The output type wrapped in Reader
//
// Kleisli[A, B] is equivalent to func(A) func(context.Context) B
//
// Kleisli arrows are fundamental for monadic composition, allowing you to chain
// operations that depend on context without explicitly passing the context through
// each function call.
//
// Example:
//
// // A Kleisli arrow that creates a greeting Reader from a name
// greet := func(name string) Reader[string] {
// return func(ctx context.Context) string {
// if deadline, ok := ctx.Deadline(); ok {
// return fmt.Sprintf("Hello %s (deadline: %v)", name, deadline)
// }
// return fmt.Sprintf("Hello %s", name)
// }
// }
//
// // Use the Kleisli arrow
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// greeting := greet("Alice")(ctx) // "Hello Alice (deadline: ...)"
Kleisli[A, B any] = R.Reader[A, Reader[B]]
// Operator represents a transformation from one Reader to another.
//
// It takes a Reader[A] and produces a Reader[B], where both readers depend on
// the same [context.Context]. This type is commonly used for operations like
// Map, Chain, and other transformations that convert readers while preserving
// the context dependency.
//
// Type Parameters:
// - A: The input Reader's result type
// - B: The output Reader's result type
//
// Operator[A, B] is equivalent to func(Reader[A]) func(context.Context) B
//
// Operators enable building pipelines of context-dependent computations where
// each step can transform the result of the previous computation while maintaining
// access to the shared context.
//
// Example:
//
// // An operator that transforms int readers to string readers
// intToString := func(r Reader[int]) Reader[string] {
// return func(ctx context.Context) string {
// value := r(ctx)
// return strconv.Itoa(value)
// }
// }
//
// // A Reader that extracts a timeout value from context
// getTimeout := func(ctx context.Context) int {
// if deadline, ok := ctx.Deadline(); ok {
// return int(time.Until(deadline).Seconds())
// }
// return 0
// }
//
// // Transform the Reader
// getTimeoutStr := intToString(getTimeout)
// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// defer cancel()
// result := getTimeoutStr(ctx) // "30" (approximately)
Operator[A, B any] = Kleisli[Reader[A], B]
)

View File

@@ -1,6 +1,7 @@
package readerio
import (
"github.com/IBM/fp-go/v2/function"
RIO "github.com/IBM/fp-go/v2/readerio"
)
@@ -73,3 +74,117 @@ func Bracket[
) ReaderIO[B] {
return RIO.Bracket(acquire, use, release)
}
// WithResource creates a higher-order function that manages a resource lifecycle for any operation.
// It returns a Kleisli arrow that takes a use function and automatically handles resource
// acquisition and cleanup using the bracket pattern.
//
// This is a more composable alternative to Bracket, allowing you to define resource management
// once and reuse it with different use functions. The resource is acquired when the returned
// Kleisli arrow is invoked, used by the provided function, and then released regardless of
// success or failure.
//
// Type Parameters:
// - A: The type of the resource to be managed
// - B: The type of the result produced by the use function
// - ANY: The type returned by the release function (typically ignored)
//
// Parameters:
// - onCreate: A ReaderIO that acquires/creates the resource
// - onRelease: A Kleisli arrow that releases/cleans up the resource
//
// Returns:
// - A Kleisli arrow that takes a use function and returns a ReaderIO managing the full lifecycle
//
// Example with database connection:
//
// // Define resource management once
// withDB := WithResource(
// // Acquire connection
// func(ctx context.Context) IO[*sql.DB] {
// return func() *sql.DB {
// db, _ := sql.Open("postgres", "connection-string")
// return db
// }
// },
// // Release connection
// func(db *sql.DB) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// db.Close()
// return nil
// }
// }
// },
// )
//
// // Reuse with different operations
// queryUsers := withDB(func(db *sql.DB) ReaderIO[[]User] {
// return func(ctx context.Context) IO[[]User] {
// return func() []User {
// // Query users from db
// return users
// }
// }
// })
//
// insertUser := withDB(func(db *sql.DB) ReaderIO[int64] {
// return func(ctx context.Context) IO[int64] {
// return func() int64 {
// // Insert user into db
// return userID
// }
// }
// })
//
// Example with file handling:
//
// withFile := WithResource(
// func(ctx context.Context) IO[*os.File] {
// return func() *os.File {
// f, _ := os.Open("data.txt")
// return f
// }
// },
// func(f *os.File) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// f.Close()
// return nil
// }
// }
// },
// )
//
// // Use for reading
// readContent := withFile(func(f *os.File) ReaderIO[string] {
// return func(ctx context.Context) IO[string] {
// return func() string {
// data, _ := io.ReadAll(f)
// return string(data)
// }
// }
// })
//
// // Use for getting file info
// getSize := withFile(func(f *os.File) ReaderIO[int64] {
// return func(ctx context.Context) IO[int64] {
// return func() int64 {
// info, _ := f.Stat()
// return info.Size()
// }
// }
// })
//
// Use Cases:
// - Database connections: Acquire connection, execute queries, close connection
// - File handles: Open file, read/write, close file
// - Network connections: Establish connection, transfer data, close connection
// - Locks: Acquire lock, perform critical section, release lock
// - Temporary resources: Create temp file/directory, use it, clean up
//
//go:inline
func WithResource[A, B, ANY any](
onCreate ReaderIO[A], onRelease Kleisli[A, ANY]) Kleisli[Kleisli[A, B], B] {
return function.Bind13of3(Bracket[A, B, ANY])(onCreate, function.Ignore2of2[B](onRelease))
}

View File

@@ -0,0 +1,454 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerio
import (
"context"
"errors"
"testing"
"github.com/IBM/fp-go/v2/io"
"github.com/stretchr/testify/assert"
)
// mockResource simulates a resource that tracks its lifecycle
type mockResource struct {
id int
acquired bool
released bool
used bool
}
// TestBracket_Success tests that Bracket properly manages resource lifecycle on success
func TestBracket_Success(t *testing.T) {
resource := &mockResource{id: 1}
// Acquire resource
acquire := func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
}
// Use resource
use := func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r.used = true
return "success"
}
}
}
// Release resource
release := func(r *mockResource, result string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
}
// Execute bracket
operation := Bracket(acquire, use, release)
result := operation(context.Background())()
// Verify lifecycle
assert.True(t, resource.acquired, "Resource should be acquired")
assert.True(t, resource.used, "Resource should be used")
assert.True(t, resource.released, "Resource should be released")
assert.Equal(t, "success", result)
}
// TestBracket_MultipleResources tests managing multiple resources
func TestBracket_MultipleResources(t *testing.T) {
resource1 := &mockResource{id: 1}
resource2 := &mockResource{id: 2}
acquire1 := func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource1.acquired = true
return resource1
}
}
use1 := func(r1 *mockResource) ReaderIO[*mockResource] {
return func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
r1.used = true
resource2.acquired = true
return resource2
}
}
}
release1 := func(r1 *mockResource, result string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r1.released = true
return nil
}
}
}
// Nested bracket for second resource
use2 := func(r2 *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r2.used = true
return "both used"
}
}
}
release2 := func(r2 *mockResource, result string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r2.released = true
return nil
}
}
}
// Compose brackets
operation := Bracket(acquire1, func(r1 *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
r2 := use1(r1)(ctx)()
return Bracket(
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource { return r2 }
},
use2,
release2,
)(ctx)
}
}, release1)
result := operation(context.Background())()
assert.True(t, resource1.acquired)
assert.True(t, resource1.used)
assert.True(t, resource1.released)
assert.True(t, resource2.acquired)
assert.True(t, resource2.used)
assert.True(t, resource2.released)
assert.Equal(t, "both used", result)
}
// TestWithResource_Success tests WithResource with successful operation
func TestWithResource_Success(t *testing.T) {
resource := &mockResource{id: 1}
// Define resource management
withResource := WithResource[*mockResource, string, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// Use resource
operation := withResource(func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r.used = true
return "result"
}
}
})
result := operation(context.Background())()
assert.True(t, resource.acquired)
assert.True(t, resource.used)
assert.True(t, resource.released)
assert.Equal(t, "result", result)
}
// TestWithResource_Reusability tests that WithResource can be reused with different operations
func TestWithResource_Reusability(t *testing.T) {
callCount := 0
withResource := WithResource[*mockResource, int, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
callCount++
return &mockResource{id: callCount, acquired: true}
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// First operation
op1 := withResource(func(r *mockResource) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
r.used = true
return r.id * 2
}
}
})
result1 := op1(context.Background())()
assert.Equal(t, 2, result1)
assert.Equal(t, 1, callCount)
// Second operation (should create new resource)
op2 := withResource(func(r *mockResource) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
r.used = true
return r.id * 3
}
}
})
result2 := op2(context.Background())()
assert.Equal(t, 6, result2)
assert.Equal(t, 2, callCount)
}
// TestWithResource_DifferentResultTypes tests WithResource with different result types
func TestWithResource_DifferentResultTypes(t *testing.T) {
resource := &mockResource{id: 42}
withResourceInt := WithResource[*mockResource, int, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// Operation returning int
opInt := withResourceInt(func(r *mockResource) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
return r.id
}
}
})
resultInt := opInt(context.Background())()
assert.Equal(t, 42, resultInt)
// Reset resource state
resource.acquired = false
resource.released = false
// Create new WithResource for string type
withResourceString := WithResource[*mockResource, string, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// Operation returning string
opString := withResourceString(func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
return "value"
}
}
})
resultString := opString(context.Background())()
assert.Equal(t, "value", resultString)
assert.True(t, resource.released)
}
// TestWithResource_ContextPropagation tests that context is properly propagated
func TestWithResource_ContextPropagation(t *testing.T) {
type contextKey string
const key contextKey = "test-key"
withResource := WithResource[string, string, any](
func(ctx context.Context) io.IO[string] {
return func() string {
value := ctx.Value(key)
if value != nil {
return value.(string)
}
return "no-value"
}
},
func(r string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
return nil
}
}
},
)
operation := withResource(func(r string) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
return r + "-processed"
}
}
})
ctx := context.WithValue(context.Background(), key, "test-value")
result := operation(ctx)()
assert.Equal(t, "test-value-processed", result)
}
// TestWithResource_ErrorInRelease tests behavior when release function encounters an error
func TestWithResource_ErrorInRelease(t *testing.T) {
resource := &mockResource{id: 1}
releaseError := errors.New("release failed")
withResource := WithResource[*mockResource, string, error](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[error] {
return func(ctx context.Context) io.IO[error] {
return func() error {
r.released = true
return releaseError
}
}
},
)
operation := withResource(func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r.used = true
return "success"
}
}
})
result := operation(context.Background())()
// Operation should succeed even if release returns error
assert.Equal(t, "success", result)
assert.True(t, resource.acquired)
assert.True(t, resource.used)
assert.True(t, resource.released)
}
// BenchmarkBracket benchmarks the Bracket function
func BenchmarkBracket(b *testing.B) {
acquire := func(ctx context.Context) io.IO[int] {
return func() int {
return 42
}
}
use := func(n int) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
return n * 2
}
}
}
release := func(n int, result int) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
return nil
}
}
}
operation := Bracket(acquire, use, release)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
operation(ctx)()
}
}
// BenchmarkWithResource benchmarks the WithResource function
func BenchmarkWithResource(b *testing.B) {
withResource := WithResource[int, int, any](
func(ctx context.Context) io.IO[int] {
return func() int {
return 42
}
},
func(n int) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
return nil
}
}
},
)
operation := withResource(func(n int) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
return n * 2
}
}
})
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
operation(ctx)()
}
}

View File

@@ -19,6 +19,9 @@ import (
"context"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/pair"
RIO "github.com/IBM/fp-go/v2/readerio"
)
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIO.
@@ -33,21 +36,24 @@ import (
// The function f returns both a new context and a CancelFunc that should be called to release resources.
//
// Type Parameters:
// - R: The input environment type that f transforms into context.Context
// - A: The original result type produced by the ReaderIO
// - B: The new output result type
//
// Parameters:
// - f: Function to transform the input context (contravariant)
// - f: Function to transform the input environment R into context.Context (contravariant)
// - g: Function to transform the output value from A to B (covariant)
//
// Returns:
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[B]
// - A Kleisli arrow that takes a ReaderIO[A] and returns a function from R to B
//
// Note: When R is context.Context, this simplifies to an Operator[A, B]
//
//go:inline
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
func Promap[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RIO.Kleisli[R, ReaderIO[A], B] {
return function.Flow2(
Local[A](f),
Map(g),
RIO.Map[R](g),
)
}
@@ -61,14 +67,87 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
//
// Type Parameters:
// - A: The result type (unchanged)
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[A]
// - A Kleisli arrow that takes a ReaderIO[A] and returns a function from R to A
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
//go:inline
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIO.Kleisli[R, ReaderIO[A], A] {
return Local[A](f)
}
// LocalIOK transforms the context using an IO effect before passing it to a ReaderIO computation.
//
// This is similar to Local, but the context transformation itself is wrapped in an IO effect,
// allowing for side-effectful context transformations. The transformation function receives
// the current context and returns an IO effect that produces a new context along with a
// cancel function. The cancel function is automatically called when the computation completes
// (via defer), ensuring proper cleanup of resources.
//
// This is useful for:
// - Context transformations that require side effects (e.g., loading configuration)
// - Lazy initialization of context values
// - Context transformations that may fail or need to perform I/O
// - Composing effectful context setup with computations
//
// Type Parameters:
// - A: The value type of the ReaderIO
//
// Parameters:
// - f: An IO Kleisli arrow that transforms the context with side effects
//
// Returns:
// - An Operator that runs the computation with the effectfully transformed context
//
// Example:
//
// import (
// "context"
// G "github.com/IBM/fp-go/v2/io"
// F "github.com/IBM/fp-go/v2/function"
// )
//
// // Context transformation with side effects (e.g., loading config)
// loadConfig := func(ctx context.Context) G.IO[ContextCancel] {
// return func() ContextCancel {
// // Simulate loading configuration
// config := loadConfigFromFile()
// newCtx := context.WithValue(ctx, "config", config)
// return pair.MakePair[context.CancelFunc](func() {}, newCtx)
// }
// }
//
// getValue := readerio.FromReader(func(ctx context.Context) string {
// if cfg := ctx.Value("config"); cfg != nil {
// return cfg.(string)
// }
// return "default"
// })
//
// result := F.Pipe1(
// getValue,
// readerio.LocalIOK[string](loadConfig),
// )
// value := result(t.Context())() // Loads config and uses it
//
// Comparison with Local:
// - Local: Takes a pure function that transforms the context
// - LocalIOK: Takes an IO effect that transforms the context, allowing side effects
func LocalIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return func(r ReaderIO[A]) ReaderIO[A] {
return func(ctx context.Context) IO[A] {
p := f(ctx)
return func() A {
otherCancel, otherCtx := pair.Unpack(p())
defer otherCancel()
return r(otherCtx)()
}
}
}
}

View File

@@ -21,6 +21,7 @@ import (
"testing"
"time"
"github.com/IBM/fp-go/v2/pair"
"github.com/stretchr/testify/assert"
)
@@ -38,9 +39,9 @@ func TestPromapBasic(t *testing.T) {
}
// Transform context and result
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) ContextCancel {
newCtx := context.WithValue(ctx, "key", 42)
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
toString := strconv.Itoa
@@ -63,9 +64,9 @@ func TestContramapBasic(t *testing.T) {
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) ContextCancel {
newCtx := context.WithValue(ctx, "key", 100)
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
adapted := Contramap[int](addKey)(getValue)
@@ -85,8 +86,9 @@ func TestLocalBasic(t *testing.T) {
}
}
addTimeout := func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, time.Second)
addTimeout := func(ctx context.Context) ContextCancel {
newCtx, cancelFct := context.WithTimeout(ctx, time.Second)
return pair.MakePair(cancelFct, newCtx)
}
adapted := Local[bool](addTimeout)(getValue)
@@ -95,3 +97,81 @@ func TestLocalBasic(t *testing.T) {
assert.True(t, result)
})
}
// TestLocalIOKBasic tests basic LocalIOK functionality
func TestLocalIOKBasic(t *testing.T) {
t.Run("context transformation with IO effect", func(t *testing.T) {
getValue := func(ctx context.Context) IO[string] {
return func() string {
if v := ctx.Value("key"); v != nil {
return v.(string)
}
return "default"
}
}
// Context transformation wrapped in IO effect
addKeyIO := func(ctx context.Context) IO[ContextCancel] {
return func() ContextCancel {
// Simulate side effect (e.g., loading config)
newCtx := context.WithValue(ctx, "key", "loaded-value")
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
}
adapted := LocalIOK[string](addKeyIO)(getValue)
result := adapted(t.Context())()
assert.Equal(t, "loaded-value", result)
})
t.Run("cleanup function is called", func(t *testing.T) {
cleanupCalled := false
getValue := func(ctx context.Context) IO[int] {
return func() int {
if v := ctx.Value("value"); v != nil {
return v.(int)
}
return 0
}
}
addValueIO := func(ctx context.Context) IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "value", 42)
cleanup := context.CancelFunc(func() {
cleanupCalled = true
})
return pair.MakePair(cleanup, newCtx)
}
}
adapted := LocalIOK[int](addValueIO)(getValue)
result := adapted(t.Context())()
assert.Equal(t, 42, result)
assert.True(t, cleanupCalled, "cleanup function should be called")
})
t.Run("works with timeout context", func(t *testing.T) {
getValue := func(ctx context.Context) IO[bool] {
return func() bool {
_, hasDeadline := ctx.Deadline()
return hasDeadline
}
}
addTimeoutIO := func(ctx context.Context) IO[ContextCancel] {
return func() ContextCancel {
newCtx, cancelFct := context.WithTimeout(ctx, time.Second)
return pair.MakePair(cancelFct, newCtx)
}
}
adapted := LocalIOK[bool](addTimeoutIO)(getValue)
result := adapted(t.Context())()
assert.True(t, result, "context should have deadline")
})
}

View File

@@ -20,6 +20,7 @@ import (
"time"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
RIO "github.com/IBM/fp-go/v2/readerio"
)
@@ -633,12 +634,15 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
//
// Type Parameters:
// - A: The value type of the ReaderIO
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: A function that transforms the context and returns a cancel function
// - f: A function that transforms the input environment R into context.Context and returns a cancel function
//
// Returns:
// - An Operator that runs the computation with the transformed context
// - A Kleisli arrow that runs the computation with the transformed context
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
// Example:
//
@@ -648,9 +652,9 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
// type key int
// const userKey key = 0
//
// addUser := readerio.Local[string](func(ctx context.Context) (context.Context, context.CancelFunc) {
// addUser := readerio.Local[string, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
// newCtx := context.WithValue(ctx, userKey, "Alice")
// return newCtx, func() {} // No-op cancel
// return pair.MakePair(func() {}, newCtx) // No-op cancel
// })
//
// getUser := readerio.FromReader(func(ctx context.Context) string {
@@ -669,19 +673,20 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
// Timeout Example:
//
// // Add a 5-second timeout to a specific operation
// withTimeout := readerio.Local[Data](func(ctx context.Context) (context.Context, context.CancelFunc) {
// return context.WithTimeout(ctx, 5*time.Second)
// withTimeout := readerio.Local[Data, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
// newCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
// return pair.MakePair(cancel, newCtx)
// })
//
// result := F.Pipe1(
// fetchData,
// withTimeout,
// )
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return func(rr ReaderIO[A]) ReaderIO[A] {
return func(ctx context.Context) IO[A] {
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIO.Kleisli[R, ReaderIO[A], A] {
return func(rr ReaderIO[A]) RIO.ReaderIO[R, A] {
return func(r R) IO[A] {
return func() A {
otherCtx, otherCancel := f(ctx)
otherCancel, otherCtx := pair.Unpack(f(r))
defer otherCancel()
return rr(otherCtx)()
}
@@ -742,8 +747,9 @@ func Local[A any](f func(context.Context) (context.Context, context.CancelFunc))
// )
// data := result(t.Context())() // Returns Data{Value: "quick"}
func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, timeout)
return Local[A](func(ctx context.Context) ContextCancel {
newCtx, cancelFct := context.WithTimeout(ctx, timeout)
return pair.MakePair(cancelFct, newCtx)
})
}
@@ -806,8 +812,9 @@ func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
// )
// data := result(parentCtx)() // Will use parent's 1-hour deadline
func WithDeadline[A any](deadline time.Time) Operator[A, A] {
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithDeadline(ctx, deadline)
return Local[A](func(ctx context.Context) ContextCancel {
newCtx, cancelFct := context.WithDeadline(ctx, deadline)
return pair.MakePair(cancelFct, newCtx)
})
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
@@ -81,4 +82,15 @@ type (
Predicate[A any] = predicate.Predicate[A]
Void = function.Void
// Pair represents a tuple of two values of types A and B.
// It is used to group two related values together.
Pair[A, B any] = pair.Pair[A, B]
// ContextCancel represents a pair of a cancel function and a context.
// It is used in operations that create new contexts with cancellation capabilities.
//
// The first element is the CancelFunc that should be called to release resources.
// The second element is the new Context that was created.
ContextCancel = Pair[context.CancelFunc, context.Context]
)

View File

@@ -21,6 +21,7 @@ import (
CIOE "github.com/IBM/fp-go/v2/context/ioresult"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/pair"
)
// WithContext wraps an existing [ReaderIOResult] and performs a context check for cancellation before delegating.
@@ -85,3 +86,7 @@ func WithContextK[A, B any](f Kleisli[A, B]) Kleisli[A, B] {
WithContext,
)
}
func pairFromContextCancel(newCtx context.Context, cancelFct context.CancelFunc) ContextCancel {
return pair.MakePair(cancelFct, newCtx)
}

View File

@@ -13,6 +13,25 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package file provides context-aware file operations that integrate with the ReaderIOResult monad.
// It offers safe, composable file I/O operations that respect context cancellation and properly
// manage resources using the RAII pattern.
//
// All operations in this package:
// - Respect context.Context for cancellation and timeouts
// - Return ReaderIOResult for composable error handling
// - Automatically manage resource cleanup
// - Are safe to use in concurrent environments
//
// # Example Usage
//
// // Read a file with automatic resource management
// readOp := ReadFile("data.txt")
// result := readOp(ctx)()
//
// // Open and manually manage a file
// fileOp := Open("config.json")
// fileResult := fileOp(ctx)()
package file
import (
@@ -29,32 +48,181 @@ import (
)
var (
// Open opens a file for reading within the given context
// Open opens a file for reading within the given context.
// The operation respects context cancellation and returns a ReaderIOResult
// that produces an os.File handle on success.
//
// The returned file handle should be closed using the Close function when no longer needed,
// or managed automatically using WithResource or ReadFile.
//
// Parameters:
// - path: The path to the file to open
//
// Returns:
// - ReaderIOResult[*os.File]: A context-aware computation that opens the file
//
// Example:
//
// openFile := Open("data.txt")
// result := openFile(ctx)()
// either.Fold(
// result,
// func(err error) { log.Printf("Error: %v", err) },
// func(f *os.File) {
// defer f.Close()
// // Use file...
// },
// )
//
// See Also:
// - ReadFile: For reading entire file contents with automatic resource management
// - Close: For closing file handles
Open = F.Flow3(
IOEF.Open,
RIOE.FromIOEither[*os.File],
RIOE.WithContext[*os.File],
)
// Remove removes a file by name
// Create creates or truncates a file for writing within the given context.
// If the file already exists, it is truncated. If it doesn't exist, it is created
// with mode 0666 (before umask).
//
// The operation respects context cancellation and returns a ReaderIOResult
// that produces an os.File handle on success.
//
// The returned file handle should be closed using the Close function when no longer needed,
// or managed automatically using WithResource or WriteFile.
//
// Parameters:
// - path: The path to the file to create or truncate
//
// Returns:
// - ReaderIOResult[*os.File]: A context-aware computation that creates the file
//
// Example:
//
// createFile := Create("output.txt")
// result := createFile(ctx)()
// either.Fold(
// result,
// func(err error) { log.Printf("Error: %v", err) },
// func(f *os.File) {
// defer f.Close()
// f.WriteString("Hello, World!")
// },
// )
//
// See Also:
// - WriteFile: For writing data to a file with automatic resource management
// - Open: For opening files for reading
// - Close: For closing file handles
Create = F.Flow3(
IOEF.Create,
RIOE.FromIOEither[*os.File],
RIOE.WithContext[*os.File],
)
// Remove removes a file by name.
// The operation returns the filename on success, allowing for easy composition
// with other file operations.
//
// Parameters:
// - name: The path to the file to remove
//
// Returns:
// - ReaderIOResult[string]: A computation that removes the file and returns its name
//
// Example:
//
// removeOp := Remove("temp.txt")
// result := removeOp(ctx)()
// either.Fold(
// result,
// func(err error) { log.Printf("Failed to remove: %v", err) },
// func(name string) { log.Printf("Removed: %s", name) },
// )
//
// See Also:
// - Open: For opening files
// - ReadFile: For reading file contents
Remove = F.Flow2(
IOEF.Remove,
RIOE.FromIOEither[string],
)
)
// Close closes an object
func Close[C io.Closer](c C) RIOE.ReaderIOResult[struct{}] {
// Close closes an io.Closer resource and returns a ReaderIOResult.
// This function is generic and works with any type that implements io.Closer,
// including os.File, network connections, and other closeable resources.
//
// The function captures any error that occurs during closing and returns it
// as part of the ReaderIOResult. On success, it returns Void (empty struct).
//
// Type Parameters:
// - C: Any type that implements io.Closer
//
// Parameters:
// - c: The resource to close
//
// Returns:
// - ReaderIOResult[Void]: A computation that closes the resource
//
// Example:
//
// file, _ := os.Open("data.txt")
// closeOp := Close(file)
// result := closeOp(ctx)()
//
// Note: This function is typically used with WithResource for automatic resource management
// rather than being called directly.
//
// See Also:
// - Open: For opening files
// - ReadFile: For reading files with automatic closing
func Close[C io.Closer](c C) ReaderIOResult[Void] {
return F.Pipe2(
c,
IOEF.Close[C],
RIOE.FromIOEither[struct{}],
RIOE.FromIOEither[Void],
)
}
// ReadFile reads a file in the scope of a context
func ReadFile(path string) RIOE.ReaderIOResult[[]byte] {
return RIOE.WithResource[[]byte](Open(path), Close[*os.File])(func(r *os.File) RIOE.ReaderIOResult[[]byte] {
// ReadFile reads the entire contents of a file in a context-aware manner.
// This function automatically manages the file resource using the RAII pattern,
// ensuring the file is properly closed even if an error occurs or the context is canceled.
//
// The operation:
// - Opens the file for reading
// - Reads all contents into a byte slice
// - Automatically closes the file when done
// - Respects context cancellation during the read operation
//
// Parameters:
// - path: The path to the file to read
//
// Returns:
// - ReaderIOResult[[]byte]: A computation that reads the file contents
//
// Example:
//
// readOp := ReadFile("config.json")
// result := readOp(ctx)()
// either.Fold(
// result,
// func(err error) { log.Printf("Read error: %v", err) },
// func(data []byte) { log.Printf("Read %d bytes", len(data)) },
// )
//
// The function uses WithResource internally to ensure proper cleanup:
//
// ReadFile(path) = WithResource(Open(path), Close)(readAllBytes)
//
// See Also:
// - Open: For opening files without automatic reading
// - Close: For closing file handles
// - WithResource: For custom resource management patterns
func ReadFile(path string) ReaderIOResult[[]byte] {
return RIOE.WithResource[[]byte](Open(path), Close[*os.File])(func(r *os.File) ReaderIOResult[[]byte] {
return func(ctx context.Context) IOE.IOEither[error, []byte] {
return func() ET.Either[error, []byte] {
return file.ReadAll(ctx, r)
@@ -62,3 +230,48 @@ func ReadFile(path string) RIOE.ReaderIOResult[[]byte] {
}
})
}
// WriteFile writes data to a file in a context-aware manner.
// This function automatically manages the file resource using the RAII pattern,
// ensuring the file is properly closed even if an error occurs or the context is canceled.
//
// If the file doesn't exist, it is created with mode 0666 (before umask).
// If the file already exists, it is truncated before writing.
//
// The operation:
// - Creates or truncates the file for writing
// - Writes all data to the file
// - Automatically closes the file when done
// - Respects context cancellation during the write operation
//
// Parameters:
// - data: The byte slice to write to the file
//
// Returns:
// - Kleisli[string, []byte]: A function that takes a file path and returns a computation
// that writes the data and returns the written bytes on success
//
// Example:
//
// writeOp := WriteFile([]byte("Hello, World!"))
// result := writeOp("output.txt")(ctx)()
// either.Fold(
// result,
// func(err error) { log.Printf("Write error: %v", err) },
// func(data []byte) { log.Printf("Wrote %d bytes", len(data)) },
// )
//
// The function uses WithResource internally to ensure proper cleanup:
//
// WriteFile(data) = Create >> WriteAll(data) >> Close
//
// See Also:
// - ReadFile: For reading file contents with automatic resource management
// - Create: For creating files without automatic writing
// - WriteAll: For writing to an already-open file handle
func WriteFile(data []byte) Kleisli[string, []byte] {
return F.Flow2(
Create,
WriteAll[*os.File](data),
)
}

View File

@@ -18,11 +18,16 @@ package file
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
R "github.com/IBM/fp-go/v2/context/readerioresult"
E "github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
J "github.com/IBM/fp-go/v2/json"
"github.com/stretchr/testify/assert"
)
type RecordType struct {
@@ -49,3 +54,267 @@ func ExampleReadFile() {
// Output:
// Right[string](Carsten)
}
func TestCreate(t *testing.T) {
ctx := context.Background()
t.Run("Success - creates new file", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_create.txt")
createOp := Create(tempFile)
result := createOp(ctx)()
assert.True(t, E.IsRight(result))
// Verify file was created
_, err := os.Stat(tempFile)
assert.NoError(t, err)
// Clean up file handle
E.MonadFold(result,
func(error) *os.File { return nil },
func(f *os.File) *os.File { f.Close(); return f },
)
})
t.Run("Success - truncates existing file", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_truncate.txt")
// Create file with initial content
err := os.WriteFile(tempFile, []byte("initial content"), 0644)
assert.NoError(t, err)
// Create should truncate
createOp := Create(tempFile)
result := createOp(ctx)()
assert.True(t, E.IsRight(result))
// Close the file
E.MonadFold(result,
func(error) *os.File { return nil },
func(f *os.File) *os.File { f.Close(); return f },
)
// Verify file was truncated
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Empty(t, content)
})
t.Run("Failure - invalid path", func(t *testing.T) {
// Try to create file in non-existent directory
invalidPath := filepath.Join(t.TempDir(), "nonexistent", "test.txt")
createOp := Create(invalidPath)
result := createOp(ctx)()
assert.True(t, E.IsLeft(result))
})
t.Run("Success - file can be written to", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_write.txt")
createOp := Create(tempFile)
result := createOp(ctx)()
assert.True(t, E.IsRight(result))
// Write to the file
E.MonadFold(result,
func(err error) *os.File { t.Fatalf("Unexpected error: %v", err); return nil },
func(f *os.File) *os.File {
defer f.Close()
_, err := f.WriteString("test content")
assert.NoError(t, err)
return f
},
)
// Verify content was written
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, "test content", string(content))
})
t.Run("Context cancellation", func(t *testing.T) {
cancelCtx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
tempFile := filepath.Join(t.TempDir(), "test_cancel.txt")
createOp := Create(tempFile)
result := createOp(cancelCtx)()
// Note: File creation itself doesn't check context, but this tests the pattern
// In practice, context cancellation would affect subsequent operations
_ = result
})
}
func TestWriteFile(t *testing.T) {
ctx := context.Background()
t.Run("Success - writes data to new file", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_write.txt")
testData := []byte("Hello, World!")
writeOp := WriteFile(testData)
result := writeOp(tempFile)(ctx)()
assert.True(t, E.IsRight(result))
// Verify returned data
E.MonadFold(result,
func(err error) []byte { t.Fatalf("Unexpected error: %v", err); return nil },
func(data []byte) []byte {
assert.Equal(t, testData, data)
return data
},
)
// Verify file content
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, testData, content)
})
t.Run("Success - overwrites existing file", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_overwrite.txt")
// Write initial content
err := os.WriteFile(tempFile, []byte("old content"), 0644)
assert.NoError(t, err)
// Overwrite with new content
newData := []byte("new content")
writeOp := WriteFile(newData)
result := writeOp(tempFile)(ctx)()
assert.True(t, E.IsRight(result))
// Verify file was overwritten
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, newData, content)
})
t.Run("Success - writes empty data", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_empty.txt")
emptyData := []byte{}
writeOp := WriteFile(emptyData)
result := writeOp(tempFile)(ctx)()
assert.True(t, E.IsRight(result))
// Verify file is empty
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Empty(t, content)
})
t.Run("Success - writes large data", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_large.txt")
largeData := make([]byte, 1024*1024) // 1MB
for i := range largeData {
largeData[i] = byte(i % 256)
}
writeOp := WriteFile(largeData)
result := writeOp(tempFile)(ctx)()
assert.True(t, E.IsRight(result))
// Verify file content
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, largeData, content)
})
t.Run("Failure - invalid path", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "nonexistent", "test.txt")
testData := []byte("test")
writeOp := WriteFile(testData)
result := writeOp(invalidPath)(ctx)()
assert.True(t, E.IsLeft(result))
})
t.Run("Success - writes binary data", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_binary.bin")
binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}
writeOp := WriteFile(binaryData)
result := writeOp(tempFile)(ctx)()
assert.True(t, E.IsRight(result))
// Verify binary content
content, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, binaryData, content)
})
t.Run("Integration - write then read", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_roundtrip.txt")
testData := []byte("Round trip test data")
// Write data
writeOp := WriteFile(testData)
writeResult := writeOp(tempFile)(ctx)()
assert.True(t, E.IsRight(writeResult))
// Read data back
readOp := ReadFile(tempFile)
readResult := readOp(ctx)()
assert.True(t, E.IsRight(readResult))
// Verify data matches
E.MonadFold(readResult,
func(err error) []byte { t.Fatalf("Unexpected error: %v", err); return nil },
func(data []byte) []byte {
assert.Equal(t, testData, data)
return data
},
)
})
t.Run("Composition with Map", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test_compose.txt")
testData := []byte("test data")
// Write and transform result
pipeline := F.Pipe1(
WriteFile(testData)(tempFile),
R.Map(func(data []byte) int { return len(data) }),
)
result := pipeline(ctx)()
assert.True(t, E.IsRight(result))
E.MonadFold(result,
func(err error) int { t.Fatalf("Unexpected error: %v", err); return 0 },
func(length int) int {
assert.Equal(t, len(testData), length)
return length
},
)
})
t.Run("Context cancellation during write", func(t *testing.T) {
cancelCtx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
tempFile := filepath.Join(t.TempDir(), "test_cancel.txt")
testData := []byte("test")
writeOp := WriteFile(testData)
result := writeOp(tempFile)(cancelCtx)()
// Note: The actual write may complete before cancellation is checked
// This test verifies the pattern works with cancelled contexts
_ = result
})
}

View File

@@ -38,7 +38,7 @@ var (
)
// CreateTemp created a temp file with proper parametrization
func CreateTemp(dir, pattern string) RIOE.ReaderIOResult[*os.File] {
func CreateTemp(dir, pattern string) ReaderIOResult[*os.File] {
return F.Pipe2(
IOEF.CreateTemp(dir, pattern),
RIOE.FromIOEither[*os.File],
@@ -47,6 +47,6 @@ func CreateTemp(dir, pattern string) RIOE.ReaderIOResult[*os.File] {
}
// WithTempFile creates a temporary file, then invokes a callback to create a resource based on the file, then close and remove the temp file
func WithTempFile[A any](f func(*os.File) RIOE.ReaderIOResult[A]) RIOE.ReaderIOResult[A] {
func WithTempFile[A any](f Kleisli[*os.File, A]) ReaderIOResult[A] {
return RIOE.WithResource[A](onCreateTempFile, onReleaseTempFile)(f)
}

View File

@@ -34,7 +34,7 @@ func TestWithTempFile(t *testing.T) {
func TestWithTempFileOnClosedFile(t *testing.T) {
res := WithTempFile(func(f *os.File) RIOE.ReaderIOResult[[]byte] {
res := WithTempFile(func(f *os.File) ReaderIOResult[[]byte] {
return F.Pipe2(
f,
onWriteAll[*os.File]([]byte("Carsten")),

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/function"
)
type (
// ReaderIOResult represents a context-aware computation that performs side effects
// and can fail with an error. This is the main type used throughout the file package
// for all file operations.
//
// ReaderIOResult[A] is equivalent to:
// func(context.Context) func() Either[error, A]
//
// The computation:
// - Takes a context.Context for cancellation and timeouts
// - Performs side effects (IO operations)
// - Returns Either an error or a value of type A
//
// See Also:
// - readerioresult.ReaderIOResult: The underlying type definition
ReaderIOResult[A any] = readerioresult.ReaderIOResult[A]
// Void represents the absence of a meaningful value, similar to unit type in other languages.
// It is used when a function performs side effects but doesn't return a meaningful result.
//
// Void is typically used as the success type in operations like Close that perform
// an action but don't produce a useful value.
//
// Example:
// Close[*os.File](file) // Returns ReaderIOResult[Void]
//
// See Also:
// - function.Void: The underlying type definition
Void = function.Void
// Kleisli represents a Kleisli arrow for ReaderIOResult.
// It is a function that takes a value of type A and returns a ReaderIOResult[B].
//
// Kleisli arrows are used for monadic composition, allowing you to chain operations
// that produce ReaderIOResults. They are particularly useful with Chain and Bind operations.
//
// Kleisli[A, B] is equivalent to:
// func(A) ReaderIOResult[B]
//
// Example:
// // A Kleisli arrow that reads a file given its path
// var readFileK Kleisli[string, []byte] = ReadFile
//
// See Also:
// - readerioresult.Kleisli: The underlying type definition
// - Operator: For transforming ReaderIOResults
Kleisli[A, B any] = readerioresult.Kleisli[A, B]
// Operator represents a transformation from one ReaderIOResult to another.
// This is useful for point-free style composition and building reusable transformations.
//
// Operator[A, B] is equivalent to:
// func(ReaderIOResult[A]) ReaderIOResult[B]
//
// Operators are used to transform computations without executing them, enabling
// powerful composition patterns.
//
// Example:
// // An operator that maps over file contents
// var toUpper Operator[[]byte, string] = Map(func(data []byte) string {
// return strings.ToUpper(string(data))
// })
//
// See Also:
// - readerioresult.Operator: The underlying type definition
// - Kleisli: For functions that produce ReaderIOResults
Operator[A, B any] = readerioresult.Operator[A, B]
)

View File

@@ -23,8 +23,8 @@ import (
F "github.com/IBM/fp-go/v2/function"
)
func onWriteAll[W io.Writer](data []byte) func(w W) RIOE.ReaderIOResult[[]byte] {
return func(w W) RIOE.ReaderIOResult[[]byte] {
func onWriteAll[W io.Writer](data []byte) Kleisli[W, []byte] {
return func(w W) ReaderIOResult[[]byte] {
return F.Pipe1(
RIOE.TryCatch(func(_ context.Context) func() ([]byte, error) {
return func() ([]byte, error) {
@@ -38,9 +38,9 @@ func onWriteAll[W io.Writer](data []byte) func(w W) RIOE.ReaderIOResult[[]byte]
}
// WriteAll uses a generator function to create a stream, writes data to it and closes it
func WriteAll[W io.WriteCloser](data []byte) func(acquire RIOE.ReaderIOResult[W]) RIOE.ReaderIOResult[[]byte] {
func WriteAll[W io.WriteCloser](data []byte) Operator[W, []byte] {
onWrite := onWriteAll[W](data)
return func(onCreate RIOE.ReaderIOResult[W]) RIOE.ReaderIOResult[[]byte] {
return func(onCreate ReaderIOResult[W]) ReaderIOResult[[]byte] {
return RIOE.WithResource[[]byte](
onCreate,
Close[W])(
@@ -50,7 +50,7 @@ func WriteAll[W io.WriteCloser](data []byte) func(acquire RIOE.ReaderIOResult[W]
}
// Write uses a generator function to create a stream, writes data to it and closes it
func Write[R any, W io.WriteCloser](acquire RIOE.ReaderIOResult[W]) func(use func(W) RIOE.ReaderIOResult[R]) RIOE.ReaderIOResult[R] {
func Write[R any, W io.WriteCloser](acquire ReaderIOResult[W]) Kleisli[Kleisli[W, R], R] {
return RIOE.WithResource[R](
acquire,
Close[W])

View File

@@ -18,6 +18,10 @@ package readerioresult
import (
"context"
"github.com/IBM/fp-go/v2/array"
"github.com/IBM/fp-go/v2/internal/witherable"
"github.com/IBM/fp-go/v2/iterator/iter"
"github.com/IBM/fp-go/v2/option"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
)
@@ -49,3 +53,43 @@ import (
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return RIOR.FilterOrElse[context.Context](pred, onFalse)
}
//go:inline
func Filter[HKTA, A any](
filter func(Predicate[A]) Endomorphism[HKTA],
) func(Predicate[A]) Operator[HKTA, HKTA] {
return witherable.Filter(
Map,
filter,
)
}
//go:inline
func FilterArray[A any](p Predicate[A]) Operator[[]A, []A] {
return Filter(array.Filter[A])(p)
}
//go:inline
func FilterIter[A any](p Predicate[A]) Operator[Seq[A], Seq[A]] {
return Filter(iter.Filter[A])(p)
}
//go:inline
func FilterMap[HKTA, HKTB, A, B any](
filter func(option.Kleisli[A, B]) Reader[HKTA, HKTB],
) func(option.Kleisli[A, B]) Operator[HKTA, HKTB] {
return witherable.FilterMap(
Map,
filter,
)
}
//go:inline
func FilterMapArray[A, B any](p option.Kleisli[A, B]) Operator[[]A, []B] {
return FilterMap(array.FilterMap[A, B])(p)
}
//go:inline
func FilterMapIter[A, B any](p option.Kleisli[A, B]) Operator[Seq[A], Seq[B]] {
return FilterMap(iter.FilterMap[A, B])(p)
}

View File

@@ -28,6 +28,7 @@ import (
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/logging"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
)
@@ -90,132 +91,7 @@ func withLoggingContext(lctx loggingContext) Endomorphism[context.Context] {
return F.Bind2nd(withLoggingContextValue, any(lctx))
}
// LogEntryExitF creates a customizable operator that wraps a ReaderIOResult computation with entry/exit callbacks.
//
// This is a more flexible version of LogEntryExit that allows you to provide custom callbacks for
// entry and exit events. The onEntry callback receives the current context and can return a modified
// context (e.g., with additional logging information). The onExit callback receives the computation
// result and can perform custom logging, metrics collection, or cleanup.
//
// The function uses the bracket pattern to ensure that:
// - The onEntry callback is executed before the computation starts
// - The computation runs with the context returned by onEntry
// - The onExit callback is executed after the computation completes (success or failure)
// - The original result is preserved and returned unchanged
// - Cleanup happens even if the computation fails
//
// Type Parameters:
// - A: The success type of the ReaderIOResult
// - ANY: The return type of the onExit callback (typically any)
//
// Parameters:
// - onEntry: A ReaderIO that receives the current context and returns a (possibly modified) context.
// This is executed before the computation starts. Use this for logging entry, adding context values,
// starting timers, or initialization logic.
// - onExit: A Kleisli function that receives the Result[A] and returns a ReaderIO[ANY].
// This is executed after the computation completes, regardless of success or failure.
// Use this for logging exit, recording metrics, cleanup, or finalization logic.
//
// Returns:
// - An Operator that wraps the ReaderIOResult computation with the custom entry/exit callbacks
//
// Example with custom context modification:
//
// type RequestID string
//
// logOp := LogEntryExitF[User, any](
// func(ctx context.Context) IO[context.Context] {
// return func() context.Context {
// reqID := RequestID(uuid.New().String())
// log.Printf("[%s] Starting operation", reqID)
// return context.WithValue(ctx, "requestID", reqID)
// }
// },
// func(res Result[User]) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// reqID := ctx.Value("requestID").(RequestID)
// return F.Pipe1(
// res,
// result.Fold(
// func(err error) any {
// log.Printf("[%s] Operation failed: %v", reqID, err)
// return nil
// },
// func(_ User) any {
// log.Printf("[%s] Operation succeeded", reqID)
// return nil
// },
// ),
// )
// }
// }
// },
// )
//
// wrapped := logOp(fetchUser(123))
//
// Example with metrics collection:
//
// import "github.com/prometheus/client_golang/prometheus"
//
// metricsOp := LogEntryExitF[Response, any](
// func(ctx context.Context) IO[context.Context] {
// return func() context.Context {
// requestCount.WithLabelValues("api_call", "started").Inc()
// return context.WithValue(ctx, "startTime", time.Now())
// }
// },
// func(res Result[Response]) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// startTime := ctx.Value("startTime").(time.Time)
// duration := time.Since(startTime).Seconds()
//
// return F.Pipe1(
// res,
// result.Fold(
// func(err error) any {
// requestCount.WithLabelValues("api_call", "error").Inc()
// requestDuration.WithLabelValues("api_call", "error").Observe(duration)
// return nil
// },
// func(_ Response) any {
// requestCount.WithLabelValues("api_call", "success").Inc()
// requestDuration.WithLabelValues("api_call", "success").Observe(duration)
// return nil
// },
// ),
// )
// }
// }
// },
// )
//
// Use Cases:
// - Custom context modification: Adding request IDs, trace IDs, or other context values
// - Structured logging: Integration with zap, logrus, or other structured loggers
// - Metrics collection: Recording operation durations, success/failure rates
// - Distributed tracing: OpenTelemetry, Jaeger integration
// - Custom monitoring: Application-specific monitoring and alerting
//
// Note: LogEntryExit is implemented using LogEntryExitF with standard logging and context management.
// Use LogEntryExitF when you need more control over the entry/exit behavior or context modification.
func LogEntryExitF[A, ANY any](
onEntry ReaderIO[context.Context],
onExit readerio.Kleisli[Result[A], ANY],
) Operator[A, A] {
bracket := F.Bind13of3(readerio.Bracket[context.Context, Result[A], ANY])(onEntry, func(newCtx context.Context, res Result[A]) ReaderIO[ANY] {
return readerio.FromIO(onExit(res)(newCtx)) // Get the exit callback for this result
})
return func(src ReaderIOResult[A]) ReaderIOResult[A] {
return bracket(F.Flow2(
src,
FromIOResult,
))
}
}
func noop() {}
// onEntry creates a ReaderIO that handles the entry logging for an operation.
// It generates a unique logging ID, captures the start time, and logs the entry message.
@@ -230,15 +106,15 @@ func LogEntryExitF[A, ANY any](
// - A ReaderIO that prepares the context with logging information and logs the entry
func onEntry(
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
cb Reader[context.Context, *slog.Logger],
nameAttr slog.Attr,
) ReaderIO[context.Context] {
) ReaderIO[ContextCancel] {
return func(ctx context.Context) IO[context.Context] {
return func(ctx context.Context) IO[ContextCancel] {
// logger
logger := cb(ctx)
return func() context.Context {
return func() ContextCancel {
// check if the logger is enabled
if logger.Enabled(ctx, logLevel) {
// Generate unique logging ID and capture start time
@@ -258,19 +134,23 @@ func onEntry(
})
withLogger := logging.WithLogger(newLogger)
return withCtx(withLogger(ctx))
return F.Pipe2(
ctx,
withLogger,
pair.Map[context.CancelFunc](withCtx),
)
}
// logging disabled
withCtx := withLoggingContext(loggingContext{
logger: logger,
isEnabled: false,
})
return withCtx(ctx)
return pair.MakePair[context.CancelFunc](noop, withCtx(ctx))
}
}
}
// onExitAny creates a Kleisli function that handles exit logging for an operation.
// onExitVoid creates a Kleisli function that handles exit logging for an operation.
// It logs either success or error based on the Result, including the operation duration.
// Only logs if logging was enabled during entry (checked via loggingContext.isEnabled).
//
@@ -280,33 +160,33 @@ func onEntry(
//
// Returns:
// - A Kleisli function that logs the exit/error and returns nil
func onExitAny(
func onExitVoid(
logLevel slog.Level,
nameAttr slog.Attr,
) readerio.Kleisli[Result[any], any] {
return func(res Result[any]) ReaderIO[any] {
return func(ctx context.Context) IO[any] {
) readerio.Kleisli[Result[Void], Void] {
return func(res Result[Void]) ReaderIO[Void] {
return func(ctx context.Context) IO[Void] {
value := getLoggingContext(ctx)
if value.isEnabled {
return func() any {
return func() Void {
// Retrieve logging information from context
durationAttr := slog.Duration("duration", time.Since(value.startTime))
// Log error with ID and duration
onError := func(err error) any {
onError := func(err error) Void {
value.logger.LogAttrs(ctx, logLevel, "[throwing]",
nameAttr,
durationAttr,
slog.Any("error", err))
return nil
return F.VOID
}
// Log success with ID and duration
onSuccess := func(_ any) any {
onSuccess := func(v Void) Void {
value.logger.LogAttrs(ctx, logLevel, "[exiting ]", nameAttr, durationAttr)
return nil
return v
}
return F.Pipe1(
@@ -316,7 +196,7 @@ func onExitAny(
}
}
// nothing to do
return io.Of[any](nil)
return io.Of(F.VOID)
}
}
}
@@ -369,18 +249,26 @@ func onExitAny(
// )
func LogEntryExitWithCallback[A any](
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
cb Reader[context.Context, *slog.Logger],
name string) Operator[A, A] {
nameAttr := slog.String("name", name)
return LogEntryExitF(
entry := F.Pipe1(
onEntry(logLevel, cb, nameAttr),
F.Flow2(
result.MapTo[A, any](nil),
onExitAny(logLevel, nameAttr),
),
readerio.LocalIOK[Result[A]],
)
exit := readerio.Tap(F.Flow2(
result.MapTo[A](F.VOID),
onExitVoid(logLevel, nameAttr),
))
return F.Flow2(
exit,
entry,
)
}
// LogEntryExit creates an operator that logs the entry and exit of a ReaderIOResult computation with timing and correlation IDs.
@@ -499,12 +387,12 @@ func LogEntryExit[A any](name string) Operator[A, A] {
func curriedLog(
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
message string) func(slog.Attr) func(context.Context) func() struct{} {
return F.Curry2(func(a slog.Attr, ctx context.Context) func() struct{} {
message string) func(slog.Attr) ReaderIO[Void] {
return F.Curry2(func(a slog.Attr, ctx context.Context) IO[Void] {
logger := cb(ctx)
return func() struct{} {
return func() Void {
logger.LogAttrs(ctx, logLevel, message, a)
return struct{}{}
return F.VOID
}
})
}
@@ -571,7 +459,7 @@ func curriedLog(
// - Conditional logging: Enable/disable logging based on logger configuration
func SLogWithCallback[A any](
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
cb Reader[context.Context, *slog.Logger],
message string) Kleisli[Result[A], A] {
return F.Pipe1(
@@ -582,18 +470,23 @@ func SLogWithCallback[A any](
curriedLog(logLevel, cb, message),
),
// preserve the original context
reader.Chain(reader.Sequence(readerio.MapTo[struct{}, Result[A]])),
reader.Chain(reader.Sequence(readerio.MapTo[Void, Result[A]])),
)
}
// SLog creates a Kleisli arrow that logs a Result value (success or error) with a message.
//
// This function logs both successful values and errors at Info level using the logger from the context.
// This function logs both successful values and errors at Info level. It retrieves the logger
// using logging.GetLoggerFromContext, which returns either:
// - The logger stored in the context via logging.WithLogger, or
// - The global logger (set via logging.SetLogger or slog.Default())
//
// It's a convenience wrapper around SLogWithCallback with standard settings.
//
// The logged output includes:
// - For success: The message with the value as a structured "value" attribute
// - For error: The message with the error as a structured "error" attribute
// The message parameter becomes the main log message text, and the Result value or error
// is attached as a structured logging attribute:
// - For success: Logs message with attribute value=<the actual value>
// - For error: Logs message with attribute error=<the error>
//
// The Result is passed through unchanged after logging, making this function transparent in the
// computation pipeline.
@@ -647,25 +540,47 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)
}
// TapSLog creates an operator that logs only successful values with a message and passes them through unchanged.
// TapSLog creates an operator that logs both successful values and errors with a message,
// and passes the ReaderIOResult through unchanged.
//
// This function is useful for debugging and monitoring values as they flow through a ReaderIOResult
// computation chain. Unlike SLog which logs both successes and errors, TapSLog only logs when the
// computation is successful. If the computation contains an error, no logging occurs and the error
// is propagated unchanged.
// computation chain. It logs both successful values and errors at Info level. It retrieves the logger
// using logging.GetLoggerFromContext, which returns either:
// - The logger stored in the context via logging.WithLogger, or
// - The global logger (set via logging.SetLogger or slog.Default())
//
// The logged output includes:
// - The provided message
// - The value being passed through (as a structured "value" attribute)
// The ReaderIOResult is returned unchanged after logging.
//
// The difference between TapSLog and SLog is their position in the pipeline:
// - SLog is a Kleisli[Result[A], A] used with Chain to intercept the raw Result
// - TapSLog is an Operator[A, A] used directly in a pipe on a ReaderIOResult[A]
//
// Both log the same information (success value or error), but TapSLog is more ergonomic
// when composing ReaderIOResult pipelines with F.Pipe.
//
// The message parameter becomes the main log message text, and the Result value or error
// is attached as a structured logging attribute:
// - For success: Logs message with attribute value=<the actual value>
// - For error: Logs message with attribute error=<the error>
//
// For example, TapSLog[User]("Fetched user") with a successful result produces:
//
// Log message: "Fetched user"
// Structured attribute: value={ID:123 Name:"Alice"}
//
// With an error result, it produces:
//
// Log message: "Fetched user"
// Structured attribute: error="user not found"
//
// Type Parameters:
// - A: The type of the value to log and pass through
// - A: The success type of the ReaderIOResult to log and pass through
//
// Parameters:
// - message: A descriptive message to include in the log entry
//
// Returns:
// - An Operator that logs successful values and returns them unchanged
// - An Operator that logs the Result (value or error) and returns the ReaderIOResult unchanged
//
// Example with simple value logging:
//
@@ -680,7 +595,7 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
// )
//
// result := pipeline(t.Context())()
// // Logs: "Fetched user" value={ID:123 Name:"Alice"}
// // If successful, logs: "Fetched user" value={ID:123 Name:"Alice"}
// // Returns: result.Of("Alice")
//
// Example in a processing pipeline:
@@ -695,36 +610,36 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
// )
//
// result := processOrder(t.Context())()
// // Logs each successful step with the intermediate values
// // If any step fails, subsequent TapSLog calls don't log
// // Logs each step with its value or error
//
// Example with error handling:
//
// pipeline := F.Pipe3(
// fetchData(id),
// TapSLog[Data]("Data fetched"),
// Chain(func(d Data) ReaderIOResult[Result] {
// Chain(func(d Data) ReaderIOResult[Data] {
// if d.IsValid() {
// return Of(processData(d))
// }
// return Left[Result](errors.New("invalid data"))
// return Left[Data](errors.New("invalid data"))
// }),
// TapSLog[Result]("Data processed"),
// TapSLog[Data]("Data processed"),
// )
//
// // If fetchData succeeds: logs "Data fetched" with the data
// // If processing succeeds: logs "Data processed" with the result
// // If processing fails: "Data processed" is NOT logged (error propagates)
// // If fetchData succeeds: logs "Data fetched" value={...}
// // If fetchData fails: logs "Data fetched" error="..."
// // If processing succeeds: logs "Data processed" value={...}
// // If processing fails: logs "Data processed" error="invalid data"
//
// Use Cases:
// - Debugging: Inspect intermediate successful values in a computation pipeline
// - Monitoring: Track successful data flow through complex operations
// - Troubleshooting: Identify where successful computations stop (last logged value before error)
// - Auditing: Log important successful values for compliance or security
// - Development: Understand data transformations during development
// - Debugging: Inspect intermediate values and errors in a computation pipeline
// - Monitoring: Track both successful and failed data flow through complex operations
// - Troubleshooting: Identify where errors are introduced or propagated
// - Auditing: Log important values and failures for compliance or security
// - Development: Understand data transformations and error paths during development
//
// Note: This function only logs successful values. Errors are silently propagated without logging.
// For logging both successes and errors, use SLog instead.
// Note: This function logs both successful values and errors. It is equivalent to SLog
// but expressed as an Operator for direct use in F.Pipe pipelines on ReaderIOResult values.
//
//go:inline
func TapSLog[A any](message string) Operator[A, A] {

View File

@@ -0,0 +1,415 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerioresult
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/logging"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// TestTapSLogComprehensive_Success verifies TapSLog logs successful values
func TestTapSLogComprehensive_Success(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("logs integer success value", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe2(
Of(42),
TapSLog[int]("Integer value"),
Map(N.Mul(2)),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.Equal(t, 84, F.Pipe1(res, getOrZero))
// Verify logging occurred
logOutput := buf.String()
assert.Contains(t, logOutput, "Integer value", "Should log the message")
assert.Contains(t, logOutput, "value=42", "Should log the success value")
assert.NotContains(t, logOutput, "error", "Should not contain error keyword for success")
})
t.Run("logs string success value", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe1(
Of("hello world"),
TapSLog[string]("String value"),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.True(t, F.Pipe1(res, isRight[string]))
// Verify logging occurred
logOutput := buf.String()
assert.Contains(t, logOutput, "String value")
assert.Contains(t, logOutput, `value="hello world"`)
})
t.Run("logs struct success value", func(t *testing.T) {
buf.Reset()
type User struct {
ID int
Name string
}
user := User{ID: 123, Name: "Alice"}
pipeline := F.Pipe1(
Of(user),
TapSLog[User]("User struct"),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.True(t, F.Pipe1(res, isRight[User]))
// Verify logging occurred with struct fields
logOutput := buf.String()
assert.Contains(t, logOutput, "User struct")
assert.Contains(t, logOutput, "ID:123")
assert.Contains(t, logOutput, "Name:Alice")
})
t.Run("logs multiple success values in pipeline", func(t *testing.T) {
buf.Reset()
step1 := F.Pipe2(
Of(10),
TapSLog[int]("Initial value"),
Map(N.Mul(2)),
)
pipeline := F.Pipe2(
step1,
TapSLog[int]("After doubling"),
Map(N.Add(5)),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.Equal(t, 25, getOrZero(res))
// Verify both log entries
logOutput := buf.String()
assert.Contains(t, logOutput, "Initial value")
assert.Contains(t, logOutput, "value=10")
assert.Contains(t, logOutput, "After doubling")
assert.Contains(t, logOutput, "value=20")
})
}
// TestTapSLogComprehensive_Error verifies TapSLog behavior with errors
func TestTapSLogComprehensive_Error(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("logs error values", func(t *testing.T) {
buf.Reset()
testErr := errors.New("test error")
pipeline := F.Pipe2(
Left[int](testErr),
TapSLog[int]("Error case"),
Map(N.Mul(2)),
)
res := pipeline(t.Context())()
// Verify error is preserved
assert.True(t, F.Pipe1(res, isLeft[int]))
// Verify logging occurred for error
logOutput := buf.String()
assert.Contains(t, logOutput, "Error case", "Should log the message")
assert.Contains(t, logOutput, "error", "Should contain error keyword")
assert.Contains(t, logOutput, "test error", "Should log the error message")
assert.NotContains(t, logOutput, "value=", "Should not log 'value=' for errors")
})
t.Run("preserves error through pipeline", func(t *testing.T) {
buf.Reset()
originalErr := errors.New("original error")
step1 := F.Pipe2(
Left[int](originalErr),
TapSLog[int]("First tap"),
Map(N.Mul(2)),
)
pipeline := F.Pipe2(
step1,
TapSLog[int]("Second tap"),
Map(N.Add(5)),
)
res := pipeline(t.Context())()
// Verify error is preserved
assert.True(t, isLeft(res))
// Verify both taps logged the error
logOutput := buf.String()
errorCount := strings.Count(logOutput, "original error")
assert.Equal(t, 2, errorCount, "Both TapSLog calls should log the error")
assert.Contains(t, logOutput, "First tap")
assert.Contains(t, logOutput, "Second tap")
})
t.Run("logs error after successful operation", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe3(
Of(10),
TapSLog[int]("Before error"),
Chain(func(n int) ReaderIOResult[int] {
return Left[int](errors.New("chain error"))
}),
TapSLog[int]("After error"),
)
res := pipeline(t.Context())()
// Verify error is present
assert.True(t, F.Pipe1(res, isLeft[int]))
// Verify both logs
logOutput := buf.String()
assert.Contains(t, logOutput, "Before error")
assert.Contains(t, logOutput, "value=10")
assert.Contains(t, logOutput, "After error")
assert.Contains(t, logOutput, "chain error")
})
}
// TestTapSLogComprehensive_EdgeCases verifies TapSLog with edge cases
func TestTapSLogComprehensive_EdgeCases(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("logs zero value", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe1(
Of(0),
TapSLog[int]("Zero value"),
)
res := pipeline(t.Context())()
assert.Equal(t, 0, F.Pipe1(res, getOrZero))
logOutput := buf.String()
assert.Contains(t, logOutput, "Zero value")
assert.Contains(t, logOutput, "value=0")
})
t.Run("logs empty string", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe1(
Of(""),
TapSLog[string]("Empty string"),
)
res := pipeline(t.Context())()
assert.True(t, F.Pipe1(res, isRight[string]))
logOutput := buf.String()
assert.Contains(t, logOutput, "Empty string")
assert.Contains(t, logOutput, `value=""`)
})
t.Run("logs nil pointer", func(t *testing.T) {
buf.Reset()
type Data struct {
Value string
}
var nilData *Data
pipeline := F.Pipe1(
Of(nilData),
TapSLog[*Data]("Nil pointer"),
)
res := pipeline(t.Context())()
assert.True(t, F.Pipe1(res, isRight[*Data]))
logOutput := buf.String()
assert.Contains(t, logOutput, "Nil pointer")
// Nil representation may vary, but should be logged
assert.NotEmpty(t, logOutput)
})
t.Run("respects logger level - disabled", func(t *testing.T) {
buf.Reset()
// Create logger that only logs errors
errorLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelError,
}))
oldLogger := logging.SetLogger(errorLogger)
defer logging.SetLogger(oldLogger)
pipeline := F.Pipe1(
Of(42),
TapSLog[int]("Should not log"),
)
res := pipeline(t.Context())()
assert.Equal(t, 42, F.Pipe1(res, getOrZero))
// Should have no logs since level is ERROR
logOutput := buf.String()
assert.Empty(t, logOutput, "Should not log when level is disabled")
})
}
// TestTapSLogComprehensive_Integration verifies TapSLog in realistic scenarios
func TestTapSLogComprehensive_Integration(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("complex pipeline with mixed success and error", func(t *testing.T) {
buf.Reset()
// Simulate a data processing pipeline
validatePositive := func(n int) ReaderIOResult[int] {
if n > 0 {
return Of(n)
}
return Left[int](errors.New("number must be positive"))
}
step1 := F.Pipe3(
Of(5),
TapSLog[int]("Input received"),
Map(N.Mul(2)),
TapSLog[int]("After multiplication"),
)
pipeline := F.Pipe2(
step1,
Chain(validatePositive),
TapSLog[int]("After validation"),
)
res := pipeline(t.Context())()
assert.Equal(t, 10, getOrZero(res))
logOutput := buf.String()
assert.Contains(t, logOutput, "Input received")
assert.Contains(t, logOutput, "value=5")
assert.Contains(t, logOutput, "After multiplication")
assert.Contains(t, logOutput, "value=10")
assert.Contains(t, logOutput, "After validation")
assert.Contains(t, logOutput, "value=10")
})
t.Run("error propagation with logging", func(t *testing.T) {
buf.Reset()
validatePositive := func(n int) ReaderIOResult[int] {
if n > 0 {
return Of(n)
}
return Left[int](errors.New("number must be positive"))
}
step1 := F.Pipe3(
Of(-5),
TapSLog[int]("Input received"),
Map(N.Mul(2)),
TapSLog[int]("After multiplication"),
)
pipeline := F.Pipe2(
step1,
Chain(validatePositive),
TapSLog[int]("After validation"),
)
res := pipeline(t.Context())()
assert.True(t, isLeft(res))
logOutput := buf.String()
// First two taps should log success
assert.Contains(t, logOutput, "Input received")
assert.Contains(t, logOutput, "value=-5")
assert.Contains(t, logOutput, "After multiplication")
assert.Contains(t, logOutput, "value=-10")
// Last tap should log error
assert.Contains(t, logOutput, "After validation")
assert.Contains(t, logOutput, "number must be positive")
})
}
// Helper functions for tests
func getOrZero(res Result[int]) int {
val, err := result.Unwrap(res)
if err == nil {
return val
}
return 0
}
func isRight[A any](res Result[A]) bool {
return result.IsRight(res)
}
func isLeft[A any](res Result[A]) bool {
return result.IsLeft(res)
}

View File

@@ -13,6 +13,7 @@ import (
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/logging"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
@@ -53,6 +54,11 @@ func TestLogEntryExitSuccess(t *testing.T) {
assert.Contains(t, logOutput, "TestOperation")
assert.Contains(t, logOutput, "ID=")
assert.Contains(t, logOutput, "duration=")
// Verify entry log appears before exit log
enteringIdx := strings.Index(logOutput, "[entering]")
exitingIdx := strings.Index(logOutput, "[exiting ]")
assert.Greater(t, exitingIdx, enteringIdx, "Exit log should appear after entry log")
}
// TestLogEntryExitError tests error operation logging
@@ -81,6 +87,11 @@ func TestLogEntryExitError(t *testing.T) {
assert.Contains(t, logOutput, "test error")
assert.Contains(t, logOutput, "ID=")
assert.Contains(t, logOutput, "duration=")
// Verify entry log appears before error log
enteringIdx := strings.Index(logOutput, "[entering]")
throwingIdx := strings.Index(logOutput, "[throwing]")
assert.Greater(t, throwingIdx, enteringIdx, "Error log should appear after entry log")
}
// TestLogEntryExitNested tests nested operations with different IDs
@@ -119,6 +130,48 @@ func TestLogEntryExitNested(t *testing.T) {
exitCount := strings.Count(logOutput, "[exiting ]")
assert.Equal(t, 2, enterCount, "Should have 2 entering logs")
assert.Equal(t, 2, exitCount, "Should have 2 exiting logs")
// Verify log ordering: Each operation logs entry before exit
// Note: Due to Chain semantics, OuterOp completes before InnerOp starts
lines := strings.Split(logOutput, "\n")
var logSequence []string
for _, line := range lines {
if strings.Contains(line, "OuterOp") && strings.Contains(line, "[entering]") {
logSequence = append(logSequence, "OuterOp-entering")
} else if strings.Contains(line, "OuterOp") && strings.Contains(line, "[exiting ]") {
logSequence = append(logSequence, "OuterOp-exiting")
} else if strings.Contains(line, "InnerOp") && strings.Contains(line, "[entering]") {
logSequence = append(logSequence, "InnerOp-entering")
} else if strings.Contains(line, "InnerOp") && strings.Contains(line, "[exiting ]") {
logSequence = append(logSequence, "InnerOp-exiting")
}
}
// Verify each operation's entry comes before its exit
assert.Equal(t, 4, len(logSequence), "Should have 4 log entries")
// Find indices
outerEnterIdx := -1
outerExitIdx := -1
innerEnterIdx := -1
innerExitIdx := -1
for i, log := range logSequence {
switch log {
case "OuterOp-entering":
outerEnterIdx = i
case "OuterOp-exiting":
outerExitIdx = i
case "InnerOp-entering":
innerEnterIdx = i
case "InnerOp-exiting":
innerExitIdx = i
}
}
// Verify entry before exit for each operation
assert.Greater(t, outerExitIdx, outerEnterIdx, "OuterOp exit should come after OuterOp entry")
assert.Greater(t, innerExitIdx, innerEnterIdx, "InnerOp exit should come after InnerOp entry")
}
// TestLogEntryExitWithCallback tests custom log level and callback
@@ -172,76 +225,6 @@ func TestLogEntryExitDisabled(t *testing.T) {
assert.Empty(t, logOutput, "Should have no logs when logging is disabled")
}
// TestLogEntryExitF tests custom entry/exit callbacks
func TestLogEntryExitF(t *testing.T) {
var entryCount, exitCount int
onEntry := func(ctx context.Context) IO[context.Context] {
return func() context.Context {
entryCount++
return ctx
}
}
onExit := func(res Result[string]) ReaderIO[any] {
return func(ctx context.Context) IO[any] {
return func() any {
exitCount++
return nil
}
}
}
operation := F.Pipe1(
Of("test"),
LogEntryExitF(onEntry, onExit),
)
res := operation(t.Context())()
assert.True(t, result.IsRight(res))
assert.Equal(t, 1, entryCount, "Entry callback should be called once")
assert.Equal(t, 1, exitCount, "Exit callback should be called once")
}
// TestLogEntryExitFWithError tests custom callbacks with error
func TestLogEntryExitFWithError(t *testing.T) {
var entryCount, exitCount int
var capturedError error
onEntry := func(ctx context.Context) IO[context.Context] {
return func() context.Context {
entryCount++
return ctx
}
}
onExit := func(res Result[string]) ReaderIO[any] {
return func(ctx context.Context) IO[any] {
return func() any {
exitCount++
if result.IsLeft(res) {
_, capturedError = result.Unwrap(res)
}
return nil
}
}
}
testErr := errors.New("custom error")
operation := F.Pipe1(
Left[string](testErr),
LogEntryExitF(onEntry, onExit),
)
res := operation(t.Context())()
assert.True(t, result.IsLeft(res))
assert.Equal(t, 1, entryCount, "Entry callback should be called once")
assert.Equal(t, 1, exitCount, "Exit callback should be called once")
assert.Equal(t, testErr, capturedError, "Should capture the error")
}
// TestLoggingIDUniqueness tests that logging IDs are unique
func TestLoggingIDUniqueness(t *testing.T) {
var buf bytes.Buffer
@@ -287,7 +270,8 @@ func TestLogEntryExitWithContextLogger(t *testing.T) {
Level: slog.LevelInfo,
}))
ctx := logging.WithLogger(contextLogger)(t.Context())
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
defer cancelFct()
operation := F.Pipe1(
Of("context value"),
@@ -546,7 +530,8 @@ func TestTapSLogWithContextLogger(t *testing.T) {
Level: slog.LevelInfo,
}))
ctx := logging.WithLogger(contextLogger)(t.Context())
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
defer cancelFct()
operation := F.Pipe2(
Of("test value"),
@@ -660,3 +645,138 @@ func TestSLogWithCallbackLogsError(t *testing.T) {
assert.Contains(t, logOutput, "warning error")
assert.Contains(t, logOutput, "level=WARN")
}
// TestTapSLogPreservesResult tests that TapSLog doesn't modify the result
func TestTapSLogPreservesResult(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
// Test with success value
successOp := F.Pipe2(
Of(42),
TapSLog[int]("Success value"),
Map(N.Mul(2)),
)
successRes := successOp(t.Context())()
assert.Equal(t, result.Of(84), successRes)
// Test with error value
testErr := errors.New("test error")
errorOp := F.Pipe2(
Left[int](testErr),
TapSLog[int]("Error value"),
Map(N.Mul(2)),
)
errorRes := errorOp(t.Context())()
assert.True(t, result.IsLeft(errorRes))
// Verify the error is preserved
_, err := result.Unwrap(errorRes)
assert.Equal(t, testErr, err)
}
// TestTapSLogChainBehavior tests that TapSLog properly chains with other operations
func TestTapSLogChainBehavior(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
// Create a pipeline with multiple TapSLog calls
step1 := F.Pipe2(
Of(1),
TapSLog[int]("Step 1"),
Map(N.Mul(2)),
)
step2 := F.Pipe2(
step1,
TapSLog[int]("Step 2"),
Map(N.Mul(3)),
)
pipeline := F.Pipe1(
step2,
TapSLog[int]("Step 3"),
)
res := pipeline(t.Context())()
assert.Equal(t, result.Of(6), res)
logOutput := buf.String()
// Verify all steps were logged
assert.Contains(t, logOutput, "Step 1")
assert.Contains(t, logOutput, "value=1")
assert.Contains(t, logOutput, "Step 2")
assert.Contains(t, logOutput, "value=2")
assert.Contains(t, logOutput, "Step 3")
assert.Contains(t, logOutput, "value=6")
}
// TestTapSLogWithNilValue tests TapSLog with nil pointer values
func TestTapSLogWithNilValue(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
type Data struct {
Value string
}
// Test with nil pointer
var nilData *Data
operation := F.Pipe1(
Of(nilData),
TapSLog[*Data]("Nil pointer value"),
)
res := operation(t.Context())()
assert.True(t, result.IsRight(res))
logOutput := buf.String()
assert.Contains(t, logOutput, "Nil pointer value")
// The exact representation of nil may vary, but it should be logged
assert.NotEmpty(t, logOutput)
}
// TestTapSLogLogsErrors verifies that TapSLog DOES log errors
// TapSLog uses SLog internally, which logs both success values and errors
func TestTapSLogLogsErrors(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
testErr := errors.New("test error message")
pipeline := F.Pipe2(
Left[int](testErr),
TapSLog[int]("Error logging test"),
Map(N.Mul(2)),
)
res := pipeline(t.Context())()
// Verify the error is preserved
assert.True(t, result.IsLeft(res))
// Verify logging occurred for the error
logOutput := buf.String()
assert.NotEmpty(t, logOutput, "TapSLog should log when the Result is an error")
assert.Contains(t, logOutput, "Error logging test")
assert.Contains(t, logOutput, "error")
assert.Contains(t, logOutput, "test error message")
}

View File

@@ -19,6 +19,11 @@ import (
"context"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/pair"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/result"
)
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIOResult.
@@ -34,21 +39,24 @@ import (
// The error type is fixed as error and remains unchanged through the transformation.
//
// Type Parameters:
// - R: The input environment type that f transforms into context.Context
// - A: The original success type produced by the ReaderIOResult
// - B: The new output success type
//
// Parameters:
// - f: Function to transform the input context (contravariant)
// - f: Function to transform the input environment R into context.Context (contravariant)
// - g: Function to transform the output success value from A to B (covariant)
//
// Returns:
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[B]
// - A Kleisli arrow that takes a ReaderIOResult[A] and returns a function from R to B
//
// Note: When R is context.Context, this simplifies to an Operator[A, B]
//
//go:inline
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
func Promap[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RIOR.Kleisli[R, ReaderIOResult[A], B] {
return function.Flow2(
Local[A](f),
Map(g),
RIOR.Map[R](g),
)
}
@@ -62,14 +70,168 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
//
// Type Parameters:
// - A: The success type (unchanged)
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - A Kleisli arrow that takes a ReaderIOResult[A] and returns a function from R to A
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
//go:inline
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIOR.Kleisli[R, ReaderIOResult[A], A] {
return Local[A](f)
}
// ContramapIOK changes the context during the execution of a ReaderIOResult using an IO effect.
// This is the contravariant functor operation with IO effects.
//
// ContramapIOK is an alias for LocalIOK and is useful for adapting a ReaderIOResult to work with
// a modified context when the transformation itself requires side effects.
//
// Type Parameters:
// - A: The success type (unchanged)
//
// Parameters:
// - f: An IO Kleisli arrow that transforms the context with side effects
//
// Returns:
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[A]
//
// See Also:
// - Contramap: For pure context transformations
// - LocalIOK: The underlying implementation
//
//go:inline
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return Local[A](f)
func ContramapIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return LocalIOK[A](f)
}
// LocalIOK transforms the context using an IO-based function before passing it to a ReaderIOResult.
// This is similar to Local but the context transformation itself is wrapped in an IO effect.
//
// The function f takes a context and returns an IO effect that produces a ContextCancel
// (a pair of CancelFunc and the new Context). This allows the context transformation to
// perform side effects.
//
// # Use Cases
//
// This function is useful for sharing information via the Context that is computed through
// side effects that cannot fail, such as:
// - Generating unique request IDs or trace IDs
// - Recording timestamps or metrics
// - Logging context information
// - Computing derived values from existing context data
//
// The side effect is executed during the context transformation, and the resulting data is
// stored in the context for downstream computations to access.
//
// # Type Parameters
//
// - A: The success type (unchanged through the transformation)
//
// # Parameters
//
// - f: An IO-based Kleisli function that transforms the context
//
// # Returns
//
// - An Operator that applies the context transformation before executing the ReaderIOResult
//
// # Example Usage
//
// // Generate a request ID via side effect and add to context
// addRequestID := func(ctx context.Context) io.IO[ContextCancel] {
// return func() ContextCancel {
// // Side effect: generate unique ID
// requestID := uuid.New().String()
// // Share the ID via context
// newCtx := context.WithValue(ctx, "requestID", requestID)
// return pair.MakePair(func() {}, newCtx)
// }
// }
// adapted := LocalIOK[int](addRequestID)(computation)
//
// # See Also
//
// - Local: For pure context transformations
// - LocalIOResultK: For context transformations that can fail
//
//go:inline
func LocalIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return LocalIOResultK[A](function.Flow2(f, ioresult.FromIO))
}
// LocalIOResultK transforms the context using an IOResult-based function before passing it to a ReaderIOResult.
// This is similar to Local but the context transformation can fail with an error.
//
// The function f takes a context and returns an IOResult that produces either an error or a ContextCancel
// (a pair of CancelFunc and the new Context). If the transformation fails, the error is propagated
// and the original ReaderIOResult is not executed.
//
// # Use Cases
//
// This function is particularly useful for sharing information via the Context that is computed
// through side effects, such as:
// - Loading configuration from a file or database
// - Fetching authentication tokens from an external service
// - Computing derived values that require I/O operations
// - Validating and enriching context with data from external sources
//
// The side effect is executed during the context transformation, and the resulting data is
// stored in the context for downstream computations to access.
//
// # Type Parameters
//
// - A: The success type (unchanged through the transformation)
//
// # Parameters
//
// - f: An IOResult-based Kleisli function that transforms the context and may fail
//
// # Returns
//
// - An Operator that applies the context transformation before executing the ReaderIOResult
//
// # Example Usage
//
// // Load configuration via side effect and add to context
// loadConfig := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
// return func() result.Result[ContextCancel] {
// // Side effect: read from file system
// config, err := os.ReadFile("config.json")
// if err != nil {
// return result.Left[ContextCancel](err)
// }
// // Share the loaded config via context
// newCtx := context.WithValue(ctx, "config", config)
// return result.Of(pair.MakePair(func() {}, newCtx))
// }
// }
// adapted := LocalIOResultK[int](loadConfig)(computation)
//
// # See Also
//
// - Local: For pure context transformations
// - LocalIOK: For context transformations with side effects that cannot fail
func LocalIOResultK[A any](f ioresult.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return func(rr ReaderIOResult[A]) ReaderIOResult[A] {
return func(ctx context.Context) IOResult[A] {
return func() Result[A] {
if ctx.Err() != nil {
return result.Left[A](context.Cause(ctx))
}
p, err := result.Unwrap(f(ctx)())
if err != nil {
return result.Left[A](err)
}
// unwrap
otherCancel, otherCtx := pair.Unpack(p)
defer otherCancel()
return rr(otherCtx)()
}
}
}
}

View File

@@ -20,6 +20,10 @@ import (
"strconv"
"testing"
"github.com/IBM/fp-go/v2/context/ioresult"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/pair"
R "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
@@ -36,9 +40,9 @@ func TestPromapBasic(t *testing.T) {
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
newCtx := context.WithValue(ctx, "key", 42)
return newCtx, func() {}
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
toString := strconv.Itoa
@@ -61,9 +65,9 @@ func TestContramapBasic(t *testing.T) {
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
newCtx := context.WithValue(ctx, "key", 100)
return newCtx, func() {}
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
adapted := Contramap[int](addKey)(getValue)
@@ -73,6 +77,105 @@ func TestContramapBasic(t *testing.T) {
})
}
// TestContramapIOK tests ContramapIOK functionality
func TestContramapIOK(t *testing.T) {
t.Run("transforms context with IO effect", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
if v := ctx.Value("requestID"); v != nil {
return R.Of(v.(string))
}
return R.Of("no-id")
}
}
addRequestID := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
// Simulate generating a request ID via side effect
requestID := "req-12345"
newCtx := context.WithValue(ctx, "requestID", requestID)
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
adapted := ContramapIOK[string](addRequestID)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of("req-12345"), result)
})
t.Run("preserves value type", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("counter"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addCounter := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "counter", 999)
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
adapted := ContramapIOK[int](addCounter)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of(999), result)
})
t.Run("calls cancel function", func(t *testing.T) {
cancelCalled := false
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("test")
}
}
addData := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "data", "value")
cancelFunc := context.CancelFunc(func() {
cancelCalled = true
})
return pair.MakePair(cancelFunc, newCtx)
}
}
adapted := ContramapIOK[string](addData)(getValue)
_ = adapted(t.Context())()
assert.True(t, cancelCalled, "cancel function should be called")
})
t.Run("handles cancelled context", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("should not reach here")
}
}
addData := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "data", "value")
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
adapted := ContramapIOK[string](addData)(getValue)
result := adapted(ctx)()
assert.True(t, R.IsLeft(result))
})
}
// TestLocalBasic tests basic Local functionality
func TestLocalBasic(t *testing.T) {
t.Run("adds value to context", func(t *testing.T) {
@@ -85,9 +188,9 @@ func TestLocalBasic(t *testing.T) {
}
}
addUser := func(ctx context.Context) (context.Context, context.CancelFunc) {
addUser := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
newCtx := context.WithValue(ctx, "user", "Alice")
return newCtx, func() {}
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
adapted := Local[string](addUser)(getValue)
@@ -96,3 +199,311 @@ func TestLocalBasic(t *testing.T) {
assert.Equal(t, R.Of("Alice"), result)
})
}
// TestLocalIOK_Success tests LocalIOK with successful context transformation
func TestLocalIOK_Success(t *testing.T) {
t.Run("transforms context with IO effect", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
if v := ctx.Value("user"); v != nil {
return R.Of(v.(string))
}
return R.Of("unknown")
}
}
addUser := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "user", "Bob")
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
adapted := LocalIOK[string](addUser)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of("Bob"), result)
})
t.Run("preserves original value type", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("count"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addCount := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "count", 42)
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
adapted := LocalIOK[int](addCount)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of(42), result)
})
}
// TestLocalIOK_CancelledContext tests LocalIOK with cancelled context
func TestLocalIOK_CancelledContext(t *testing.T) {
t.Run("returns error when context is cancelled", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("should not reach here")
}
}
addUser := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "user", "Charlie")
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
adapted := LocalIOK[string](addUser)(getValue)
result := adapted(ctx)()
assert.True(t, R.IsLeft(result))
})
}
// TestLocalIOK_CancelFuncCalled tests that CancelFunc is properly called
func TestLocalIOK_CancelFuncCalled(t *testing.T) {
t.Run("calls cancel function after execution", func(t *testing.T) {
cancelCalled := false
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("test")
}
}
addUser := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "user", "Dave")
cancelFunc := context.CancelFunc(func() {
cancelCalled = true
})
return pair.MakePair(cancelFunc, newCtx)
}
}
adapted := LocalIOK[string](addUser)(getValue)
_ = adapted(t.Context())()
assert.True(t, cancelCalled, "cancel function should be called")
})
}
// TestLocalIOResultK_Success tests LocalIOResultK with successful context transformation
func TestLocalIOResultK_Success(t *testing.T) {
t.Run("transforms context with IOResult effect", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
if v := ctx.Value("role"); v != nil {
return R.Of(v.(string))
}
return R.Of("guest")
}
}
addRole := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
newCtx := context.WithValue(ctx, "role", "admin")
return R.Of(pair.MakePair(context.CancelFunc(func() {}), newCtx))
}
}
adapted := LocalIOResultK[string](addRole)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of("admin"), result)
})
t.Run("preserves original value type", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("score"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addScore := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
newCtx := context.WithValue(ctx, "score", 100)
return R.Of(pair.MakePair(context.CancelFunc(func() {}), newCtx))
}
}
adapted := LocalIOResultK[int](addScore)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of(100), result)
})
}
// TestLocalIOResultK_Failure tests LocalIOResultK with failed context transformation
func TestLocalIOResultK_Failure(t *testing.T) {
t.Run("propagates transformation error", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("should not reach here")
}
}
failTransform := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
return R.Left[ContextCancel](assert.AnError)
}
}
adapted := LocalIOResultK[string](failTransform)(getValue)
result := adapted(t.Context())()
assert.True(t, R.IsLeft(result))
_, err := R.UnwrapError(result)
assert.Equal(t, assert.AnError, err)
})
t.Run("does not execute original computation on transformation failure", func(t *testing.T) {
executed := false
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
executed = true
return R.Of("should not execute")
}
}
failTransform := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
return R.Left[ContextCancel](assert.AnError)
}
}
adapted := LocalIOResultK[string](failTransform)(getValue)
_ = adapted(t.Context())()
assert.False(t, executed, "original computation should not execute")
})
}
// TestLocalIOResultK_CancelledContext tests LocalIOResultK with cancelled context
func TestLocalIOResultK_CancelledContext(t *testing.T) {
t.Run("returns error when context is cancelled", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("should not reach here")
}
}
addRole := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
newCtx := context.WithValue(ctx, "role", "user")
return R.Of(pair.MakePair(context.CancelFunc(func() {}), newCtx))
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
adapted := LocalIOResultK[string](addRole)(getValue)
result := adapted(ctx)()
assert.True(t, R.IsLeft(result))
})
}
// TestLocalIOResultK_CancelFuncCalled tests that CancelFunc is properly called
func TestLocalIOResultK_CancelFuncCalled(t *testing.T) {
t.Run("calls cancel function after successful execution", func(t *testing.T) {
cancelCalled := false
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("test")
}
}
addRole := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
newCtx := context.WithValue(ctx, "role", "user")
cancelFunc := context.CancelFunc(func() {
cancelCalled = true
})
return R.Of(pair.MakePair(cancelFunc, newCtx))
}
}
adapted := LocalIOResultK[string](addRole)(getValue)
_ = adapted(t.Context())()
assert.True(t, cancelCalled, "cancel function should be called")
})
t.Run("does not call cancel function on transformation failure", func(t *testing.T) {
cancelCalled := false
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("test")
}
}
failTransform := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
cancelFunc := context.CancelFunc(func() {
cancelCalled = true
})
_ = cancelFunc // avoid unused warning
return R.Left[ContextCancel](assert.AnError)
}
}
adapted := LocalIOResultK[string](failTransform)(getValue)
_ = adapted(t.Context())()
assert.False(t, cancelCalled, "cancel function should not be called on failure")
})
}
// TestLocalIOResultK_Integration tests integration with other operations
func TestLocalIOResultK_Integration(t *testing.T) {
t.Run("composes with Map", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("value"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addValue := func(ctx context.Context) ioresult.IOResult[ContextCancel] {
return func() R.Result[ContextCancel] {
newCtx := context.WithValue(ctx, "value", 10)
return R.Of(pair.MakePair(context.CancelFunc(func() {}), newCtx))
}
}
double := func(x int) int { return x * 2 }
adapted := F.Flow2(
LocalIOResultK[int](addValue),
Map(double),
)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of(20), result)
})
}

View File

@@ -28,10 +28,10 @@ import (
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/readeroption"
"github.com/IBM/fp-go/v2/result"
)
const (
@@ -452,7 +452,7 @@ func TapEitherK[A, B any](f either.Kleisli[error, A, B]) Operator[A, A] {
// Returns a function that chains Option-returning functions into ReaderIOResult.
//
//go:inline
func ChainOptionK[A, B any](onNone func() error) func(option.Kleisli[A, B]) Operator[A, B] {
func ChainOptionK[A, B any](onNone Lazy[error]) func(option.Kleisli[A, B]) Operator[A, B] {
return RIOR.ChainOptionK[context.Context, A, B](onNone)
}
@@ -800,7 +800,7 @@ func FromReaderResult[A any](ma ReaderResult[A]) ReaderIOResult[A] {
}
//go:inline
func FromReaderOption[A any](onNone func() error) Kleisli[ReaderOption[context.Context, A], A] {
func FromReaderOption[A any](onNone Lazy[error]) Kleisli[ReaderOption[context.Context, A], A] {
return RIOR.FromReaderOption[context.Context, A](onNone)
}
@@ -895,17 +895,17 @@ func TapReaderIOK[A, B any](f readerio.Kleisli[A, B]) Operator[A, A] {
}
//go:inline
func ChainReaderOptionK[A, B any](onNone func() error) func(readeroption.Kleisli[context.Context, A, B]) Operator[A, B] {
func ChainReaderOptionK[A, B any](onNone Lazy[error]) func(readeroption.Kleisli[context.Context, A, B]) Operator[A, B] {
return RIOR.ChainReaderOptionK[context.Context, A, B](onNone)
}
//go:inline
func ChainFirstReaderOptionK[A, B any](onNone func() error) func(readeroption.Kleisli[context.Context, A, B]) Operator[A, A] {
func ChainFirstReaderOptionK[A, B any](onNone Lazy[error]) func(readeroption.Kleisli[context.Context, A, B]) Operator[A, A] {
return RIOR.ChainFirstReaderOptionK[context.Context, A, B](onNone)
}
//go:inline
func TapReaderOptionK[A, B any](onNone func() error) func(readeroption.Kleisli[context.Context, A, B]) Operator[A, A] {
func TapReaderOptionK[A, B any](onNone Lazy[error]) func(readeroption.Kleisli[context.Context, A, B]) Operator[A, A] {
return RIOR.TapReaderOptionK[context.Context, A, B](onNone)
}
@@ -1010,12 +1010,15 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
//
// Type Parameters:
// - A: The value type of the ReaderIOResult
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: A function that transforms the context and returns a cancel function
// - f: A function that transforms the input environment R into context.Context and returns a cancel function
//
// Returns:
// - An Operator that runs the computation with the transformed context
// - A Kleisli arrow that runs the computation with the transformed context
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
// Example:
//
@@ -1025,9 +1028,9 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
// type key int
// const userKey key = 0
//
// addUser := readerioresult.Local[string](func(ctx context.Context) (context.Context, context.CancelFunc) {
// addUser := readerioresult.Local[string, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
// newCtx := context.WithValue(ctx, userKey, "Alice")
// return newCtx, func() {} // No-op cancel
// return pair.MakePair(func() {}, newCtx) // No-op cancel
// })
//
// getUser := readerioresult.FromReader(func(ctx context.Context) string {
@@ -1046,27 +1049,19 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
// Timeout Example:
//
// // Add a 5-second timeout to a specific operation
// withTimeout := readerioresult.Local[Data](func(ctx context.Context) (context.Context, context.CancelFunc) {
// return context.WithTimeout(ctx, 5*time.Second)
// withTimeout := readerioresult.Local[Data, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
// newCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
// return pair.MakePair(cancel, newCtx)
// })
//
// result := F.Pipe1(
// fetchData,
// withTimeout,
// )
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return func(rr ReaderIOResult[A]) ReaderIOResult[A] {
return func(ctx context.Context) IOResult[A] {
return func() Result[A] {
if ctx.Err() != nil {
return result.Left[A](context.Cause(ctx))
}
otherCtx, otherCancel := f(ctx)
defer otherCancel()
return rr(otherCtx)()
}
}
}
//
//go:inline
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIOR.Kleisli[R, ReaderIOResult[A], A] {
return readerio.Local[Result[A]](f)
}
// WithTimeout adds a timeout to the context for a ReaderIOResult computation.
@@ -1123,9 +1118,10 @@ func Local[A any](f func(context.Context) (context.Context, context.CancelFunc))
// )
// value, err := result(t.Context())() // Returns (Data{Value: "quick"}, nil)
func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, timeout)
})
return Local[A](
func(ctx context.Context) ContextCancel {
return pairFromContextCancel(context.WithTimeout(ctx, timeout))
})
}
// WithDeadline adds an absolute deadline to the context for a ReaderIOResult computation.
@@ -1188,7 +1184,7 @@ func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
// )
// value, err := result(parentCtx)() // Will use parent's 1-hour deadline
func WithDeadline[A any](deadline time.Time) Operator[A, A] {
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithDeadline(ctx, deadline)
return Local[A](func(ctx context.Context) ContextCancel {
return pairFromContextCancel(context.WithDeadline(ctx, deadline))
})
}

View File

@@ -52,7 +52,7 @@ import (
//
// - f: A Kleisli arrow (A => ReaderIOResult[Trampoline[A, B]]) that:
// - Takes the current state A
// - Returns a ReaderIOResult that depends on [context.Context]
// - Returns a ReaderIOResult that depends on context.Context
// - Can fail with error (Left in the outer Either)
// - Produces Trampoline[A, B] to control recursion flow (Right in the outer Either)
//
@@ -60,13 +60,13 @@ import (
//
// A Kleisli arrow (A => ReaderIOResult[B]) that:
// - Takes an initial state A
// - Returns a ReaderIOResult that requires [context.Context]
// - Returns a ReaderIOResult that requires context.Context
// - Can fail with error or context cancellation
// - Produces the final result B after recursion completes
//
// # Context Cancellation
//
// Unlike the base [readerioresult.TailRec], this version automatically integrates
// Unlike the base readerioresult.TailRec, this version automatically integrates
// context cancellation checking:
// - Each recursive iteration checks if the context is cancelled
// - If cancelled, recursion terminates immediately with a cancellation error
@@ -92,9 +92,9 @@ import (
//
// # Example: Cancellable Countdown
//
// countdownStep := func(n int) readerioresult.ReaderIOResult[tailrec.Trampoline[int, string]] {
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[int, string]] {
// return func() either.Either[error, tailrec.Trampoline[int, string]] {
// countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
// return func(ctx context.Context) IOEither[Trampoline[int, string]] {
// return func() Either[Trampoline[int, string]] {
// if n <= 0 {
// return either.Right[error](tailrec.Land[int]("Done!"))
// }
@@ -105,7 +105,7 @@ import (
// }
// }
//
// countdown := readerioresult.TailRec(countdownStep)
// countdown := TailRec(countdownStep)
//
// // With cancellation
// ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
@@ -119,9 +119,9 @@ import (
// processed []string
// }
//
// processStep := func(state ProcessState) readerioresult.ReaderIOResult[tailrec.Trampoline[ProcessState, []string]] {
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[ProcessState, []string]] {
// return func() either.Either[error, tailrec.Trampoline[ProcessState, []string]] {
// processStep := func(state ProcessState) ReaderIOResult[Trampoline[ProcessState, []string]] {
// return func(ctx context.Context) IOEither[Trampoline[ProcessState, []string]] {
// return func() Either[Trampoline[ProcessState, []string]] {
// if len(state.files) == 0 {
// return either.Right[error](tailrec.Land[ProcessState](state.processed))
// }
@@ -140,7 +140,7 @@ import (
// }
// }
//
// processFiles := readerioresult.TailRec(processStep)
// processFiles := TailRec(processStep)
// ctx, cancel := context.WithCancel(t.Context())
//
// // Can be cancelled at any point during processing
@@ -158,7 +158,7 @@ import (
// still respecting context cancellation:
//
// // Safe for very large inputs with cancellation support
// largeCountdown := readerioresult.TailRec(countdownStep)
// largeCountdown := TailRec(countdownStep)
// ctx := t.Context()
// result := largeCountdown(1000000)(ctx)() // Safe, no stack overflow
//
@@ -171,11 +171,11 @@ import (
//
// # See Also
//
// - [readerioresult.TailRec]: Base tail recursion without automatic context checking
// - [WithContext]: Context cancellation wrapper used internally
// - [Chain]: For sequencing ReaderIOResult computations
// - [Ask]: For accessing the context
// - [Left]/[Right]: For creating error/success values
// - readerioresult.TailRec: Base tail recursion without automatic context checking
// - WithContext: Context cancellation wrapper used internally
// - Chain: For sequencing ReaderIOResult computations
// - Ask: For accessing the context
// - Left/Right: For creating error/success values
//
//go:inline
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {

View File

@@ -30,6 +30,16 @@ import (
"github.com/stretchr/testify/require"
)
// CustomError is a test error type
type CustomError struct {
Code int
Message string
}
func (e *CustomError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}
func TestTailRec_BasicRecursion(t *testing.T) {
// Test basic countdown recursion
countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
@@ -432,3 +442,237 @@ func TestTailRec_ContextWithValue(t *testing.T) {
assert.Equal(t, E.Of[error]("Done!"), result)
}
func TestTailRec_MultipleErrorTypes(t *testing.T) {
// Test that different error types are properly handled
errorStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
if n == 5 {
customErr := &CustomError{Code: 500, Message: "custom error"}
return E.Left[Trampoline[int, string]](error(customErr))
}
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
errorRecursion := TailRec(errorStep)
result := errorRecursion(10)(t.Context())()
assert.True(t, E.IsLeft(result))
err := E.ToError(result)
customErr, ok := err.(*CustomError)
require.True(t, ok, "Expected CustomError type")
assert.Equal(t, 500, customErr.Code)
assert.Equal(t, "custom error", customErr.Message)
}
func TestTailRec_ContextCancelDuringBounce(t *testing.T) {
// Test cancellation happens between bounces, not during computation
var iterationCount int32
ctx, cancel := context.WithCancel(t.Context())
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
count := atomic.AddInt32(&iterationCount, 1)
// Cancel after 3 iterations
if count == 3 {
cancel()
}
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
slowRecursion := TailRec(slowStep)
result := slowRecursion(10)(ctx)()
// Should be cancelled after a few iterations
assert.True(t, E.IsLeft(result))
iterations := atomic.LoadInt32(&iterationCount)
assert.Greater(t, iterations, int32(2))
assert.Less(t, iterations, int32(10))
}
func TestTailRec_EmptyState(t *testing.T) {
// Test with empty/zero-value state
type EmptyState struct{}
emptyStep := func(state EmptyState) ReaderIOResult[Trampoline[EmptyState, int]] {
return func(ctx context.Context) IOEither[Trampoline[EmptyState, int]] {
return func() Either[Trampoline[EmptyState, int]] {
return E.Right[error](tailrec.Land[EmptyState](42))
}
}
}
emptyRecursion := TailRec(emptyStep)
result := emptyRecursion(EmptyState{})(t.Context())()
assert.Equal(t, E.Of[error](42), result)
}
func TestTailRec_PointerState(t *testing.T) {
// Test with pointer state to ensure proper handling
type Node struct {
Value int
Next *Node
}
// Create a linked list: 1 -> 2 -> 3 -> nil
list := &Node{Value: 1, Next: &Node{Value: 2, Next: &Node{Value: 3, Next: nil}}}
sumStep := func(node *Node) ReaderIOResult[Trampoline[*Node, int]] {
return func(ctx context.Context) IOEither[Trampoline[*Node, int]] {
return func() Either[Trampoline[*Node, int]] {
if node == nil {
return E.Right[error](tailrec.Land[*Node](0))
}
if node.Next == nil {
return E.Right[error](tailrec.Land[*Node](node.Value))
}
// Accumulate value and continue
node.Next.Value += node.Value
return E.Right[error](tailrec.Bounce[int](node.Next))
}
}
}
sumList := TailRec(sumStep)
result := sumList(list)(t.Context())()
assert.Equal(t, E.Of[error](6), result) // 1 + 2 + 3 = 6
}
func TestTailRec_ConcurrentCancellation(t *testing.T) {
// Test that cancellation works correctly with concurrent operations
var iterationCount int32
ctx, cancel := context.WithCancel(t.Context())
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
atomic.AddInt32(&iterationCount, 1)
time.Sleep(10 * time.Millisecond)
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
slowRecursion := TailRec(slowStep)
// Cancel from another goroutine after 50ms
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
result := slowRecursion(20)(ctx)()
elapsed := time.Since(start)
// Should be cancelled
assert.True(t, E.IsLeft(result))
// Should complete quickly due to cancellation
assert.Less(t, elapsed, 100*time.Millisecond)
// Should have executed some but not all iterations
iterations := atomic.LoadInt32(&iterationCount)
assert.Greater(t, iterations, int32(0))
assert.Less(t, iterations, int32(20))
}
func TestTailRec_NestedContextValues(t *testing.T) {
// Test that nested context values are preserved
type contextKey string
const (
key1 contextKey = "key1"
key2 contextKey = "key2"
)
nestedStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
val1 := ctx.Value(key1)
val2 := ctx.Value(key2)
require.NotNil(t, val1)
require.NotNil(t, val2)
assert.Equal(t, "value1", val1.(string))
assert.Equal(t, "value2", val2.(string))
if n <= 0 {
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
nestedRecursion := TailRec(nestedStep)
ctx := context.WithValue(t.Context(), key1, "value1")
ctx = context.WithValue(ctx, key2, "value2")
result := nestedRecursion(3)(ctx)()
assert.Equal(t, E.Of[error]("Done!"), result)
}
func BenchmarkTailRec_SimpleCountdown(b *testing.B) {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
return func() Either[Trampoline[int, int]] {
if n <= 0 {
return E.Right[error](tailrec.Land[int](0))
}
return E.Right[error](tailrec.Bounce[int](n - 1))
}
}
}
countdown := TailRec(countdownStep)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = countdown(1000)(ctx)()
}
}
func BenchmarkTailRec_WithCancellation(b *testing.B) {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
return func() Either[Trampoline[int, int]] {
if n <= 0 {
return E.Right[error](tailrec.Land[int](0))
}
return E.Right[error](tailrec.Bounce[int](n - 1))
}
}
}
countdown := TailRec(countdownStep)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = countdown(1000)(ctx)()
}
}

View File

@@ -17,6 +17,7 @@ package readerioresult
import (
"context"
"iter"
"github.com/IBM/fp-go/v2/consumer"
"github.com/IBM/fp-go/v2/context/ioresult"
@@ -55,6 +56,10 @@ type (
// Either[A] is equivalent to Either[error, A] from the either package.
Either[A any] = either.Either[error, A]
// Result represents a computation that can either succeed with a value of type A
// or fail with an error. This is an alias for result.Result[A].
//
// Result[A] is equivalent to Either[error, A]
Result[A any] = result.Result[A]
// Lazy represents a deferred computation that produces a value of type A when executed.
@@ -73,6 +78,10 @@ type (
// IOEither[A] is equivalent to func() Either[error, A]
IOEither[A any] = ioeither.IOEither[error, A]
// IOResult represents a side-effectful computation that can fail with an error.
// This combines IO (side effects) with Result (error handling).
//
// IOResult[A] is equivalent to func() Result[A]
IOResult[A any] = ioresult.IOResult[A]
// Reader represents a computation that depends on a context of type R.
@@ -118,6 +127,13 @@ type (
// result := fetchUser("123")(ctx)()
ReaderIOResult[A any] = RIOR.ReaderIOResult[context.Context, A]
// Kleisli represents a Kleisli arrow for ReaderIOResult.
// It is a function that takes a value of type A and returns a ReaderIOResult[B].
//
// Kleisli arrows are used for monadic composition, allowing you to chain operations
// that produce ReaderIOResults. They are particularly useful with Chain operations.
//
// Kleisli[A, B] is equivalent to func(A) ReaderIOResult[B]
Kleisli[A, B any] = reader.Reader[A, ReaderIOResult[B]]
// Operator represents a transformation from one ReaderIOResult to another.
@@ -133,26 +149,82 @@ type (
// result := toUpper(computation)
Operator[A, B any] = Kleisli[ReaderIOResult[A], B]
ReaderResult[A any] = readerresult.ReaderResult[A]
ReaderEither[R, E, A any] = readereither.ReaderEither[R, E, A]
ReaderOption[R, A any] = readeroption.ReaderOption[R, A]
// ReaderResult represents a context-dependent computation that can fail.
// This is specialized to use context.Context as the context type.
//
// ReaderResult[A] is equivalent to func(context.Context) Result[A]
ReaderResult[A any] = readerresult.ReaderResult[A]
// ReaderEither represents a context-dependent computation that can fail.
// It takes a context of type R and produces an Either[E, A].
//
// ReaderEither[R, E, A] is equivalent to func(R) Either[E, A]
ReaderEither[R, E, A any] = readereither.ReaderEither[R, E, A]
// ReaderOption represents a context-dependent computation that may not produce a value.
// It takes a context of type R and produces an Option[A].
//
// ReaderOption[R, A] is equivalent to func(R) Option[A]
ReaderOption[R, A any] = readeroption.ReaderOption[R, A]
// Endomorphism represents a function from a type to itself.
// It is used for transformations that preserve the type.
//
// Endomorphism[A] is equivalent to func(A) A
Endomorphism[A any] = endomorphism.Endomorphism[A]
// Consumer represents a function that consumes a value without producing a result.
// It is used for side effects like logging or updating state.
//
// Consumer[A] is equivalent to func(A)
Consumer[A any] = consumer.Consumer[A]
// Prism represents an optic for working with sum types (tagged unions).
// It provides a way to focus on a specific variant of a sum type.
Prism[S, T any] = prism.Prism[S, T]
Lens[S, T any] = lens.Lens[S, T]
// Lens represents an optic for working with product types (records/structs).
// It provides a way to focus on a specific field of a product type.
Lens[S, T any] = lens.Lens[S, T]
// Trampoline represents a computation that can be executed in a stack-safe manner.
// It is used for tail-recursive computations that would otherwise overflow the stack.
Trampoline[B, L any] = tailrec.Trampoline[B, L]
// Predicate represents a function that tests a value of type A.
// It returns true if the value satisfies the predicate, false otherwise.
//
// Predicate[A] is equivalent to func(A) bool
Predicate[A any] = predicate.Predicate[A]
// Pair represents a tuple of two values of types A and B.
// It is used to group two related values together.
Pair[A, B any] = pair.Pair[A, B]
// IORef represents a mutable reference that can be safely accessed in IO computations.
// It provides thread-safe read and write operations.
IORef[A any] = ioref.IORef[A]
// State represents a stateful computation that transforms a state of type S
// and produces a value of type A.
//
// State[S, A] is equivalent to func(S) Pair[A, S]
State[S, A any] = state.State[S, A]
// Void represents the absence of a value, similar to unit type in other languages.
// It is used when a function performs side effects but doesn't return a meaningful value.
Void = function.Void
// ContextCancel represents a pair of a cancel function and a context.
// It is used in operations that create new contexts with cancellation capabilities.
//
// The first element is the CancelFunc that should be called to release resources.
// The second element is the new Context that was created.
ContextCancel = Pair[context.CancelFunc, context.Context]
// Seq is an iterator over sequences of individual values.
// When called as seq(yield), seq calls yield(v) for each value v in the sequence,
// stopping early if yield returns false.
// See the [iter] package documentation for more details.
Seq[A any] = iter.Seq[A]
)

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerreaderioresult
import (
"context"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/ioresult"
)
// Eitherize converts a function that returns a value and error into a ReaderReaderIOResult.
//
// This function takes a function that accepts an outer context R and context.Context,
// returning a value T and an error, and converts it into a ReaderReaderIOResult[R, T].
// The error is automatically converted into the Left case of the Result, while successful
// values become the Right case.
//
// This is particularly useful for integrating standard Go error-handling patterns into
// the functional programming style of ReaderReaderIOResult. It is especially helpful
// for adapting interface member functions that accept a context. When you have an
// interface method with signature (receiver, context.Context) (T, error), you can
// use Eitherize to convert it into a ReaderReaderIOResult where the receiver becomes
// the outer reader context R.
//
// # Type Parameters
//
// - R: The outer reader context type (e.g., application configuration)
// - T: The success value type
//
// # Parameters
//
// - f: A function that takes R and context.Context and returns (T, error)
//
// # Returns
//
// - ReaderReaderIOResult[R, T]: A computation that depends on R and context.Context,
// performs IO, and produces a Result[T]
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// }
//
// // A function using standard Go error handling
// func fetchUser(cfg AppConfig, ctx context.Context) (*User, error) {
// // Implementation that may return an error
// return &User{ID: 1, Name: "Alice"}, nil
// }
//
// // Convert to ReaderReaderIOResult
// fetchUserRR := Eitherize(fetchUser)
//
// // Use in functional composition
// result := F.Pipe1(
// fetchUserRR,
// Map[AppConfig](func(u *User) string { return u.Name }),
// )
//
// // Execute with config and context
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
// outcome := result(cfg)(context.Background())()
//
// # Adapting Interface Methods
//
// Eitherize is particularly useful for adapting interface member functions:
//
// type UserRepository interface {
// GetUser(ctx context.Context, id int) (*User, error)
// }
//
// type UserRepo struct {
// db *sql.DB
// }
//
// func (r *UserRepo) GetUser(ctx context.Context, id int) (*User, error) {
// // Implementation
// return &User{ID: id}, nil
// }
//
// // Adapt the method by binding the first parameter (receiver)
// repo := &UserRepo{db: db}
// getUserRR := Eitherize(func(id int, ctx context.Context) (*User, error) {
// return repo.GetUser(ctx, id)
// })
//
// // Now getUserRR has type: ReaderReaderIOResult[int, *User]
// // The receiver (repo) is captured in the closure
// // The id becomes the outer reader context R
//
// # See Also
//
// - Eitherize1: For functions that take an additional parameter
// - ioresult.Eitherize2: The underlying conversion function
func Eitherize[R, T any](f func(R, context.Context) (T, error)) ReaderReaderIOResult[R, T] {
return F.Pipe1(
ioresult.Eitherize2(f),
F.Curry2,
)
}
// Eitherize1 converts a function that takes an additional parameter and returns a value
// and error into a Kleisli arrow.
//
// This function takes a function that accepts an outer context R, context.Context, and
// an additional parameter A, returning a value T and an error, and converts it into a
// Kleisli arrow (A -> ReaderReaderIOResult[R, T]). The error is automatically converted
// into the Left case of the Result, while successful values become the Right case.
//
// This is useful for creating composable operations that depend on both contexts and
// an input value, following standard Go error-handling patterns. It is especially helpful
// for adapting interface member functions that accept a context and additional parameters.
// When you have an interface method with signature (receiver, context.Context, A) (T, error),
// you can use Eitherize1 to convert it into a Kleisli arrow where the receiver becomes
// the outer reader context R and A becomes the input parameter.
//
// # Type Parameters
//
// - R: The outer reader context type (e.g., application configuration)
// - A: The input parameter type
// - T: The success value type
//
// # Parameters
//
// - f: A function that takes R, context.Context, and A, returning (T, error)
//
// # Returns
//
// - Kleisli[R, A, T]: A function from A to ReaderReaderIOResult[R, T]
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// }
//
// // A function using standard Go error handling
// func fetchUserByID(cfg AppConfig, ctx context.Context, id int) (*User, error) {
// // Implementation that may return an error
// return &User{ID: id, Name: "Alice"}, nil
// }
//
// // Convert to Kleisli arrow
// fetchUserKleisli := Eitherize1(fetchUserByID)
//
// // Use in functional composition with Chain
// pipeline := F.Pipe1(
// Of[AppConfig](123),
// Chain[AppConfig](fetchUserKleisli),
// )
//
// // Execute with config and context
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
// outcome := pipeline(cfg)(context.Background())()
//
// # Adapting Interface Methods
//
// Eitherize1 is particularly useful for adapting interface member functions with parameters:
//
// type UserRepository interface {
// GetUserByID(ctx context.Context, id int) (*User, error)
// UpdateUser(ctx context.Context, user *User) error
// }
//
// type UserRepo struct {
// db *sql.DB
// }
//
// func (r *UserRepo) GetUserByID(ctx context.Context, id int) (*User, error) {
// // Implementation
// return &User{ID: id}, nil
// }
//
// // Adapt the method - receiver becomes R, id becomes A
// repo := &UserRepo{db: db}
// getUserKleisli := Eitherize1(func(r *UserRepo, ctx context.Context, id int) (*User, error) {
// return r.GetUserByID(ctx, id)
// })
//
// // Now getUserKleisli has type: Kleisli[*UserRepo, int, *User]
// // Which is: func(int) ReaderReaderIOResult[*UserRepo, *User]
// // Use it in composition:
// pipeline := F.Pipe1(
// Of[*UserRepo](123),
// Chain[*UserRepo](getUserKleisli),
// )
// result := pipeline(repo)(context.Background())()
//
// # See Also
//
// - Eitherize: For functions without an additional parameter
// - Chain: For composing Kleisli arrows
// - ioresult.Eitherize3: The underlying conversion function
func Eitherize1[R, A, T any](f func(R, context.Context, A) (T, error)) Kleisli[R, A, T] {
return F.Flow2(
F.Bind3of3(ioresult.Eitherize3(f)),
F.Curry2,
)
}

View File

@@ -0,0 +1,507 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerreaderioresult
import (
"context"
"errors"
"fmt"
"strconv"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
type TestConfig struct {
Prefix string
MaxLen int
}
var testConfig = TestConfig{
Prefix: "test",
MaxLen: 100,
}
// TestEitherize_Success tests successful conversion with Eitherize
func TestEitherize_Success(t *testing.T) {
t.Run("converts successful function to ReaderReaderIOResult", func(t *testing.T) {
// Arrange
successFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
return cfg.Prefix + "-success", nil
}
rr := Eitherize(successFunc)
// Act
outcome := rr(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of("test-success"), outcome)
})
t.Run("preserves context values", func(t *testing.T) {
// Arrange
type ctxKey string
key := ctxKey("testKey")
expectedValue := "contextValue"
contextFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
value := ctx.Value(key)
if value == nil {
return "", errors.New("context value not found")
}
return value.(string), nil
}
rr := Eitherize(contextFunc)
ctx := context.WithValue(context.Background(), key, expectedValue)
// Act
outcome := rr(testConfig)(ctx)()
// Assert
assert.Equal(t, result.Of(expectedValue), outcome)
})
t.Run("works with different types", func(t *testing.T) {
// Arrange
intFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return cfg.MaxLen, nil
}
rr := Eitherize(intFunc)
// Act
outcome := rr(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of(100), outcome)
})
}
// TestEitherize_Failure tests error handling with Eitherize
func TestEitherize_Failure(t *testing.T) {
t.Run("converts error to Left", func(t *testing.T) {
// Arrange
expectedErr := errors.New("operation failed")
failFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
return "", expectedErr
}
rr := Eitherize(failFunc)
// Act
outcome := rr(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsLeft(outcome))
assert.Equal(t, result.Left[string](expectedErr), outcome)
})
t.Run("preserves error message", func(t *testing.T) {
// Arrange
expectedErr := fmt.Errorf("validation error: field is required")
failFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return 0, expectedErr
}
rr := Eitherize(failFunc)
// Act
outcome := rr(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsLeft(outcome))
leftValue := result.MonadFold(outcome,
F.Identity[error],
func(int) error { return nil },
)
assert.Equal(t, expectedErr, leftValue)
})
}
// TestEitherize_EdgeCases tests edge cases for Eitherize
func TestEitherize_EdgeCases(t *testing.T) {
t.Run("handles nil context", func(t *testing.T) {
// Arrange
nilCtxFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
if ctx == nil {
return "nil-context", nil
}
return "non-nil-context", nil
}
rr := Eitherize(nilCtxFunc)
// Act
outcome := rr(testConfig)(nil)()
// Assert
assert.Equal(t, result.Of("nil-context"), outcome)
})
t.Run("handles zero value config", func(t *testing.T) {
// Arrange
zeroFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
return cfg.Prefix, nil
}
rr := Eitherize(zeroFunc)
// Act
outcome := rr(TestConfig{})(context.Background())()
// Assert
assert.Equal(t, result.Of(""), outcome)
})
t.Run("handles pointer types", func(t *testing.T) {
// Arrange
type User struct {
Name string
}
ptrFunc := func(cfg TestConfig, ctx context.Context) (*User, error) {
return &User{Name: "Alice"}, nil
}
rr := Eitherize(ptrFunc)
// Act
outcome := rr(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsRight(outcome))
user := result.MonadFold(outcome,
func(error) *User { return nil },
F.Identity[*User],
)
assert.NotNil(t, user)
assert.Equal(t, "Alice", user.Name)
})
}
// TestEitherize_Integration tests integration with other operations
func TestEitherize_Integration(t *testing.T) {
t.Run("composes with Map", func(t *testing.T) {
// Arrange
baseFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return 42, nil
}
rr := Eitherize(baseFunc)
// Act
pipeline := F.Pipe1(
rr,
Map[TestConfig](func(n int) string { return strconv.Itoa(n) }),
)
outcome := pipeline(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of("42"), outcome)
})
t.Run("composes with Chain", func(t *testing.T) {
// Arrange
firstFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return 10, nil
}
secondFunc := func(n int) ReaderReaderIOResult[TestConfig, string] {
return Of[TestConfig](fmt.Sprintf("value: %d", n))
}
// Act
pipeline := F.Pipe1(
Eitherize(firstFunc),
Chain[TestConfig](secondFunc),
)
outcome := pipeline(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of("value: 10"), outcome)
})
}
// TestEitherize1_Success tests successful conversion with Eitherize1
func TestEitherize1_Success(t *testing.T) {
t.Run("converts successful function to Kleisli", func(t *testing.T) {
// Arrange
addFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
return n + cfg.MaxLen, nil
}
kleisli := Eitherize1(addFunc)
// Act
outcome := kleisli(10)(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of(110), outcome)
})
t.Run("works with string input", func(t *testing.T) {
// Arrange
concatFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
return cfg.Prefix + "-" + s, nil
}
kleisli := Eitherize1(concatFunc)
// Act
outcome := kleisli("input")(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of("test-input"), outcome)
})
t.Run("preserves context in Kleisli", func(t *testing.T) {
// Arrange
type ctxKey string
key := ctxKey("multiplier")
multiplyFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
multiplier := ctx.Value(key)
if multiplier == nil {
return n, nil
}
return n * multiplier.(int), nil
}
kleisli := Eitherize1(multiplyFunc)
ctx := context.WithValue(context.Background(), key, 3)
// Act
outcome := kleisli(5)(testConfig)(ctx)()
// Assert
assert.Equal(t, result.Of(15), outcome)
})
}
// TestEitherize1_Failure tests error handling with Eitherize1
func TestEitherize1_Failure(t *testing.T) {
t.Run("converts error to Left in Kleisli", func(t *testing.T) {
// Arrange
expectedErr := errors.New("division by zero")
divideFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
if n == 0 {
return 0, expectedErr
}
return 100 / n, nil
}
kleisli := Eitherize1(divideFunc)
// Act
outcome := kleisli(0)(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsLeft(outcome))
assert.Equal(t, result.Left[int](expectedErr), outcome)
})
t.Run("preserves error context", func(t *testing.T) {
// Arrange
validateFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
if len(s) > cfg.MaxLen {
return "", fmt.Errorf("string too long: %d > %d", len(s), cfg.MaxLen)
}
return s, nil
}
kleisli := Eitherize1(validateFunc)
longString := string(make([]byte, 200))
// Act
outcome := kleisli(longString)(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsLeft(outcome))
leftValue := result.MonadFold(outcome,
F.Identity[error],
func(string) error { return nil },
)
assert.Contains(t, leftValue.Error(), "string too long")
})
}
// TestEitherize1_EdgeCases tests edge cases for Eitherize1
func TestEitherize1_EdgeCases(t *testing.T) {
t.Run("handles zero value input", func(t *testing.T) {
// Arrange
zeroFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
return n, nil
}
kleisli := Eitherize1(zeroFunc)
// Act
outcome := kleisli(0)(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of(0), outcome)
})
t.Run("handles pointer input", func(t *testing.T) {
// Arrange
type Input struct {
Value int
}
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
if in == nil {
return 0, errors.New("nil input")
}
return in.Value, nil
}
kleisli := Eitherize1(ptrFunc)
// Act
outcome := kleisli(&Input{Value: 42})(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of(42), outcome)
})
t.Run("handles nil pointer input", func(t *testing.T) {
// Arrange
type Input struct {
Value int
}
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
if in == nil {
return 0, errors.New("nil input")
}
return in.Value, nil
}
kleisli := Eitherize1(ptrFunc)
// Act
outcome := kleisli((*Input)(nil))(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsLeft(outcome))
})
}
// TestEitherize1_Integration tests integration with other operations
func TestEitherize1_Integration(t *testing.T) {
t.Run("composes with Chain", func(t *testing.T) {
// Arrange
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
return strconv.Atoi(s)
}
doubleFunc := func(n int) ReaderReaderIOResult[TestConfig, int] {
return Of[TestConfig](n * 2)
}
parseKleisli := Eitherize1(parseFunc)
// Act
pipeline := F.Pipe2(
Of[TestConfig]("42"),
Chain[TestConfig](parseKleisli),
Chain[TestConfig](doubleFunc),
)
outcome := pipeline(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of(84), outcome)
})
t.Run("handles error in chain", func(t *testing.T) {
// Arrange
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
return strconv.Atoi(s)
}
parseKleisli := Eitherize1(parseFunc)
// Act
pipeline := F.Pipe1(
Of[TestConfig]("not-a-number"),
Chain(parseKleisli),
)
outcome := pipeline(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsLeft(outcome))
})
t.Run("composes multiple Kleisli arrows", func(t *testing.T) {
// Arrange
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
return strconv.Atoi(s)
}
formatFunc := func(cfg TestConfig, ctx context.Context, n int) (string, error) {
return fmt.Sprintf("%s-%d", cfg.Prefix, n), nil
}
parseKleisli := Eitherize1(parseFunc)
formatKleisli := Eitherize1(formatFunc)
// Act
pipeline := F.Pipe2(
Of[TestConfig]("123"),
Chain[TestConfig](parseKleisli),
Chain[TestConfig](formatKleisli),
)
outcome := pipeline(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of("test-123"), outcome)
})
}
// TestEitherize_TypeSafety tests type safety across different scenarios
func TestEitherize_TypeSafety(t *testing.T) {
t.Run("Eitherize with complex types", func(t *testing.T) {
// Arrange
type ComplexResult struct {
Data map[string]int
Count int
}
complexFunc := func(cfg TestConfig, ctx context.Context) (ComplexResult, error) {
return ComplexResult{
Data: map[string]int{"key": 42},
Count: 1,
}, nil
}
rr := Eitherize(complexFunc)
// Act
outcome := rr(testConfig)(context.Background())()
// Assert
assert.True(t, result.IsRight(outcome))
value := result.MonadFold(outcome,
func(error) ComplexResult { return ComplexResult{} },
F.Identity[ComplexResult],
)
assert.Equal(t, 42, value.Data["key"])
assert.Equal(t, 1, value.Count)
})
t.Run("Eitherize1 with different input and output types", func(t *testing.T) {
// Arrange
type Input struct {
ID int
}
type Output struct {
Name string
}
convertFunc := func(cfg TestConfig, ctx context.Context, in Input) (Output, error) {
return Output{Name: fmt.Sprintf("%s-%d", cfg.Prefix, in.ID)}, nil
}
kleisli := Eitherize1(convertFunc)
// Act
outcome := kleisli(Input{ID: 99})(testConfig)(context.Background())()
// Assert
assert.Equal(t, result.Of(Output{Name: "test-99"}), outcome)
})
}

View File

@@ -0,0 +1,48 @@
package readerreaderioresult
import (
"github.com/IBM/fp-go/v2/array"
"github.com/IBM/fp-go/v2/internal/witherable"
"github.com/IBM/fp-go/v2/iterator/iter"
"github.com/IBM/fp-go/v2/option"
)
//go:inline
func Filter[C, HKTA, A any](
filter func(Predicate[A]) Endomorphism[HKTA],
) func(Predicate[A]) Operator[C, HKTA, HKTA] {
return witherable.Filter(
Map[C],
filter,
)
}
//go:inline
func FilterArray[C, A any](p Predicate[A]) Operator[C, []A, []A] {
return Filter[C](array.Filter[A])(p)
}
//go:inline
func FilterIter[C, A any](p Predicate[A]) Operator[C, Seq[A], Seq[A]] {
return Filter[C](iter.Filter[A])(p)
}
//go:inline
func FilterMap[C, HKTA, HKTB, A, B any](
filter func(option.Kleisli[A, B]) Reader[HKTA, HKTB],
) func(option.Kleisli[A, B]) Operator[C, HKTA, HKTB] {
return witherable.FilterMap(
Map[C],
filter,
)
}
//go:inline
func FilterMapArray[C, A, B any](p option.Kleisli[A, B]) Operator[C, []A, []B] {
return FilterMap[C](array.FilterMap[A, B])(p)
}
//go:inline
func FilterMapIter[C, A, B any](p option.Kleisli[A, B]) Operator[C, Seq[A], Seq[B]] {
return FilterMap[C](iter.FilterMap[A, B])(p)
}

View File

@@ -3,6 +3,7 @@ package readerreaderioresult
import (
"context"
"github.com/IBM/fp-go/v2/context/reader"
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioresult"
@@ -13,6 +14,17 @@ import (
// Local modifies the outer environment before passing it to a computation.
// Useful for providing different configurations to sub-computations.
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A function that transforms R2 to R1
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
//go:inline
func Local[A, R1, R2 any](f func(R2) R1) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.Local[context.Context, error, A](f)
@@ -102,6 +114,29 @@ func LocalIOResultK[A, R1, R2 any](f ioresult.Kleisli[R2, R1]) func(ReaderReader
return RRIOE.LocalIOEitherK[context.Context, A](f)
}
// LocalResultK transforms the outer environment of a ReaderReaderIOResult using a Result-based Kleisli arrow.
// It allows you to modify the outer environment through a pure computation that can fail before
// passing it to the ReaderReaderIOResult.
//
// This is useful when the outer environment transformation is a pure computation that can fail,
// such as parsing, validation, or data transformation that doesn't require IO effects.
//
// The transformation happens in two stages:
// 1. The Result function f is executed with the R2 environment to produce Result[R1]
// 2. If successful (Ok), the R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
// 3. If failed (Err), the error is propagated without executing the ReaderReaderIOResult
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A Result Kleisli arrow that transforms R2 to R1 with pure computation that can fail
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
//go:inline
func LocalResultK[A, R1, R2 any](f result.Kleisli[R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.LocalEitherK[context.Context, A](f)
@@ -162,6 +197,90 @@ func LocalReaderIOResultK[A, R1, R2 any](f readerioresult.Kleisli[R2, R1]) func(
return RRIOE.LocalReaderIOEitherK[A](f)
}
// LocalReaderK transforms the outer environment of a ReaderReaderIOResult using a Reader-based Kleisli arrow.
// It allows you to modify the outer environment through a pure computation that depends on the inner context
// before passing it to the ReaderReaderIOResult.
//
// This is useful when the outer environment transformation is a pure computation that requires access
// to the inner context (e.g., context.Context) but cannot fail. Common use cases include:
// - Extracting configuration from context values
// - Computing derived environment values based on context
// - Transforming environment based on context metadata
//
// The transformation happens in two stages:
// 1. The Reader function f is executed with the R2 outer environment and inner context to produce an R1 value
// 2. The resulting R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A Reader Kleisli arrow that transforms R2 to R1 using the inner context
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
// Example Usage:
//
// type ctxKey string
// const configKey ctxKey = "config"
//
// // Extract config from context and transform environment
// extractConfig := func(path string) reader.Reader[DetailedConfig] {
// return func(ctx context.Context) DetailedConfig {
// if cfg, ok := ctx.Value(configKey).(DetailedConfig); ok {
// return cfg
// }
// return DetailedConfig{Host: "localhost", Port: 8080}
// }
// }
//
// // Use the config
// useConfig := func(cfg DetailedConfig) readerioresult.ReaderIOResult[string] {
// return func(ctx context.Context) ioresult.IOResult[string] {
// return func() result.Result[string] {
// return result.Of(fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
// }
// }
// }
//
// // Compose using LocalReaderK
// adapted := LocalReaderK[string](extractConfig)(useConfig)
// ctx := context.WithValue(context.Background(), configKey, DetailedConfig{Host: "api.example.com", Port: 443})
// result := adapted("config.json")(ctx)() // Result: "api.example.com:443"
//
//go:inline
func LocalReaderK[A, R1, R2 any](f reader.Kleisli[R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.LocalReaderK[error, A](f)
}
// LocalReaderReaderIOEitherK transforms the outer environment of a ReaderReaderIOResult using a ReaderReaderIOResult-based Kleisli arrow.
// It allows you to modify the outer environment through a computation that depends on both the outer environment
// and the inner context, and can perform IO effects that may fail.
//
// This is the most powerful Local variant, useful when the outer environment transformation requires:
// - Access to both the outer environment (R2) and inner context (context.Context)
// - IO operations that can fail
// - Complex transformations that need the full computational context
//
// The transformation happens in three stages:
// 1. The ReaderReaderIOResult effect f is executed with the R2 outer environment and inner context
// 2. If successful (Ok), the R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
// 3. If failed (Err), the error is propagated without executing the ReaderReaderIOResult
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A ReaderReaderIOResult Kleisli arrow that transforms R2 to R1 with full context-aware IO effects that can fail
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
//go:inline
func LocalReaderReaderIOEitherK[A, R1, R2 any](f Kleisli[R2, R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.LocalReaderReaderIOEitherK[A](f)

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"testing"
"github.com/IBM/fp-go/v2/context/reader"
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioresult"
@@ -426,3 +427,226 @@ func TestLocalReaderIOResultK(t *testing.T) {
assert.True(t, result.IsLeft(resErr))
})
}
// TestLocalReaderK tests LocalReaderK functionality
func TestLocalReaderK(t *testing.T) {
ctx := context.Background()
t.Run("basic Reader transformation", func(t *testing.T) {
// Reader that transforms string path to SimpleConfig using context
loadConfig := func(path string) reader.Reader[SimpleConfig] {
return func(ctx context.Context) SimpleConfig {
// Could extract values from context here
return SimpleConfig{Port: 8080}
}
}
// ReaderReaderIOResult that uses the config
useConfig := func(cfg SimpleConfig) readerioresult.ReaderIOResult[string] {
return func(ctx context.Context) ioresult.IOResult[string] {
return func() result.Result[string] {
return result.Of(fmt.Sprintf("Port: %d", cfg.Port))
}
}
}
// Compose using LocalReaderK
adapted := LocalReaderK[string](loadConfig)(useConfig)
res := adapted("config.json")(ctx)()
assert.Equal(t, result.Of("Port: 8080"), res)
})
t.Run("extract config from context", func(t *testing.T) {
type ctxKey string
const configKey ctxKey = "config"
// Reader that extracts config from context
extractConfig := func(path string) reader.Reader[DetailedConfig] {
return func(ctx context.Context) DetailedConfig {
if cfg, ok := ctx.Value(configKey).(DetailedConfig); ok {
return cfg
}
// Default config if not in context
return DetailedConfig{Host: "localhost", Port: 8080}
}
}
// Use the config
useConfig := func(cfg DetailedConfig) readerioresult.ReaderIOResult[string] {
return func(ctx context.Context) ioresult.IOResult[string] {
return func() result.Result[string] {
return result.Of(fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
}
}
}
adapted := LocalReaderK[string](extractConfig)(useConfig)
// With context value
ctxWithConfig := context.WithValue(ctx, configKey, DetailedConfig{Host: "api.example.com", Port: 443})
res := adapted("ignored")(ctxWithConfig)()
assert.Equal(t, result.Of("api.example.com:443"), res)
// Without context value (uses default)
resDefault := adapted("ignored")(ctx)()
assert.Equal(t, result.Of("localhost:8080"), resDefault)
})
t.Run("context-aware transformation", func(t *testing.T) {
type ctxKey string
const multiplierKey ctxKey = "multiplier"
// Reader that uses context to compute environment
computeValue := func(base int) reader.Reader[int] {
return func(ctx context.Context) int {
if mult, ok := ctx.Value(multiplierKey).(int); ok {
return base * mult
}
return base
}
}
// Use the computed value
formatValue := func(val int) readerioresult.ReaderIOResult[string] {
return func(ctx context.Context) ioresult.IOResult[string] {
return func() result.Result[string] {
return result.Of(fmt.Sprintf("Value: %d", val))
}
}
}
adapted := LocalReaderK[string](computeValue)(formatValue)
// With multiplier in context
ctxWithMult := context.WithValue(ctx, multiplierKey, 10)
res := adapted(5)(ctxWithMult)()
assert.Equal(t, result.Of("Value: 50"), res)
// Without multiplier (uses base value)
resBase := adapted(5)(ctx)()
assert.Equal(t, result.Of("Value: 5"), resBase)
})
t.Run("compose multiple LocalReaderK", func(t *testing.T) {
type ctxKey string
const prefixKey ctxKey = "prefix"
// First transformation: int -> string using context
intToString := func(n int) reader.Reader[string] {
return func(ctx context.Context) string {
if prefix, ok := ctx.Value(prefixKey).(string); ok {
return fmt.Sprintf("%s-%d", prefix, n)
}
return fmt.Sprintf("%d", n)
}
}
// Second transformation: string -> SimpleConfig
stringToConfig := func(s string) reader.Reader[SimpleConfig] {
return func(ctx context.Context) SimpleConfig {
return SimpleConfig{Port: len(s) * 100}
}
}
// Use the config
formatConfig := func(cfg SimpleConfig) readerioresult.ReaderIOResult[string] {
return func(ctx context.Context) ioresult.IOResult[string] {
return func() result.Result[string] {
return result.Of(fmt.Sprintf("Port: %d", cfg.Port))
}
}
}
// Compose transformations
step1 := LocalReaderK[string](stringToConfig)(formatConfig)
step2 := LocalReaderK[string](intToString)(step1)
// With prefix in context
ctxWithPrefix := context.WithValue(ctx, prefixKey, "test")
res := step2(42)(ctxWithPrefix)()
// "test-42" has length 7, so port = 700
assert.Equal(t, result.Of("Port: 700"), res)
// Without prefix
resNoPrefix := step2(42)(ctx)()
// "42" has length 2, so port = 200
assert.Equal(t, result.Of("Port: 200"), resNoPrefix)
})
t.Run("error propagation in ReaderReaderIOResult", func(t *testing.T) {
// Reader transformation (pure, cannot fail)
loadConfig := func(path string) reader.Reader[SimpleConfig] {
return func(ctx context.Context) SimpleConfig {
return SimpleConfig{Port: 8080}
}
}
// ReaderReaderIOResult that returns an error
failingOperation := func(cfg SimpleConfig) readerioresult.ReaderIOResult[string] {
return func(ctx context.Context) ioresult.IOResult[string] {
return func() result.Result[string] {
return result.Left[string](errors.New("operation failed"))
}
}
}
adapted := LocalReaderK[string](loadConfig)(failingOperation)
res := adapted("config.json")(ctx)()
// Error from the ReaderReaderIOResult should propagate
assert.True(t, result.IsLeft(res))
})
t.Run("real-world: environment selection based on context", func(t *testing.T) {
type Environment string
const (
Dev Environment = "dev"
Prod Environment = "prod"
)
type ctxKey string
const envKey ctxKey = "environment"
type EnvConfig struct {
Name string
}
// Reader that selects config based on context environment
selectConfig := func(envName EnvConfig) reader.Reader[DetailedConfig] {
return func(ctx context.Context) DetailedConfig {
env := Dev
if e, ok := ctx.Value(envKey).(Environment); ok {
env = e
}
switch env {
case Prod:
return DetailedConfig{Host: "api.production.com", Port: 443}
default:
return DetailedConfig{Host: "localhost", Port: 8080}
}
}
}
// Use the selected config
useConfig := func(cfg DetailedConfig) readerioresult.ReaderIOResult[string] {
return func(ctx context.Context) ioresult.IOResult[string] {
return func() result.Result[string] {
return result.Of(fmt.Sprintf("Connecting to %s:%d", cfg.Host, cfg.Port))
}
}
}
adapted := LocalReaderK[string](selectConfig)(useConfig)
// Production environment
ctxProd := context.WithValue(ctx, envKey, Prod)
resProd := adapted(EnvConfig{Name: "app"})(ctxProd)()
assert.Equal(t, result.Of("Connecting to api.production.com:443"), resProd)
// Development environment (default)
resDev := adapted(EnvConfig{Name: "app"})(ctx)()
assert.Equal(t, result.Of("Connecting to localhost:8080"), resDev)
})
}

View File

@@ -834,7 +834,7 @@ func Flap[R, B, A any](a A) Operator[R, func(A) B, B] {
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadMapLeft[R, A any](fa ReaderReaderIOResult[R, A], f Endmorphism[error]) ReaderReaderIOResult[R, A] {
func MonadMapLeft[R, A any](fa ReaderReaderIOResult[R, A], f Endomorphism[error]) ReaderReaderIOResult[R, A] {
return RRIOE.MonadMapLeft(fa, f)
}
@@ -843,7 +843,7 @@ func MonadMapLeft[R, A any](fa ReaderReaderIOResult[R, A], f Endmorphism[error])
// This is the curried version that returns an operator.
//
//go:inline
func MapLeft[R, A any](f Endmorphism[error]) Operator[R, A, A] {
func MapLeft[R, A any](f Endomorphism[error]) Operator[R, A, A] {
return RRIOE.MapLeft[R, context.Context, A](f)
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/iterator/iter"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/traversal/result"
@@ -146,9 +147,15 @@ type (
// It's an alias for predicate.Predicate[A].
Predicate[A any] = predicate.Predicate[A]
// Endmorphism represents a function from type A to type A.
// Endomorphism represents a function from type A to type A.
// It's an alias for endomorphism.Endomorphism[A].
Endmorphism[A any] = endomorphism.Endomorphism[A]
Endomorphism[A any] = endomorphism.Endomorphism[A]
// Seq is an iterator over sequences of individual values.
// When called as seq(yield), seq calls yield(v) for each value v in the sequence,
// stopping early if yield returns false.
// See the [iter] package documentation for more details.
Seq[A any] = iter.Seq[A]
Void = function.Void
)

View File

@@ -24,6 +24,7 @@ import (
"github.com/IBM/fp-go/v2/logging"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
@@ -104,7 +105,8 @@ func TestSLogWithContextLogger(t *testing.T) {
Level: slog.LevelInfo,
}))
ctx := logging.WithLogger(contextLogger)(t.Context())
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
defer cancelFct()
res1 := result.Of("test value")
logged := SLog[string]("Context logger test")(res1)(ctx)

View File

@@ -19,6 +19,8 @@ import (
"context"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/pair"
RR "github.com/IBM/fp-go/v2/readerresult"
)
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderResult.
@@ -34,21 +36,24 @@ import (
// The error type is fixed as error and remains unchanged through the transformation.
//
// Type Parameters:
// - R: The input environment type that f transforms into context.Context
// - A: The original success type produced by the ReaderResult
// - B: The new output success type
//
// Parameters:
// - f: Function to transform the input context (contravariant)
// - f: Function to transform the input environment R into context.Context (contravariant)
// - g: Function to transform the output success value from A to B (covariant)
//
// Returns:
// - An Operator that takes a ReaderResult[A] and returns a ReaderResult[B]
// - A Kleisli arrow that takes a ReaderResult[A] and returns a function from R to B
//
// Note: When R is context.Context, this simplifies to an Operator[A, B]
//
//go:inline
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
func Promap[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RR.Kleisli[R, ReaderResult[A], B] {
return function.Flow2(
Local[A](f),
Map(g),
RR.Map[R](g),
)
}
@@ -62,15 +67,18 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
//
// Type Parameters:
// - A: The success type (unchanged)
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - An Operator that takes a ReaderResult[A] and returns a ReaderResult[A]
// - A Kleisli arrow that takes a ReaderResult[A] and returns a function from R to A
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
//go:inline
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RR.Kleisli[R, ReaderResult[A], A] {
return Local[A](f)
}
@@ -89,16 +97,19 @@ func Contramap[A any](f func(context.Context) (context.Context, context.CancelFu
//
// Type Parameters:
// - A: The result type (unchanged)
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - An Operator that takes a ReaderResult[A] and returns a ReaderResult[A]
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return func(rr ReaderResult[A]) ReaderResult[A] {
return func(ctx context.Context) Result[A] {
otherCtx, otherCancel := f(ctx)
// - A Kleisli arrow that takes a ReaderResult[A] and returns a function from R to A
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RR.Kleisli[R, ReaderResult[A], A] {
return func(rr ReaderResult[A]) RR.ReaderResult[R, A] {
return func(r R) Result[A] {
otherCancel, otherCtx := pair.Unpack(f(r))
defer otherCancel()
return rr(otherCtx)
}

View File

@@ -20,6 +20,7 @@ import (
"strconv"
"testing"
"github.com/IBM/fp-go/v2/pair"
R "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
@@ -34,9 +35,9 @@ func TestPromapBasic(t *testing.T) {
return R.Of(0)
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
newCtx := context.WithValue(ctx, "key", 42)
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
toString := strconv.Itoa
@@ -57,9 +58,9 @@ func TestContramapBasic(t *testing.T) {
return R.Of(0)
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
newCtx := context.WithValue(ctx, "key", 100)
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
adapted := Contramap[int](addKey)(getValue)
@@ -79,9 +80,9 @@ func TestLocalBasic(t *testing.T) {
return R.Of("unknown")
}
addUser := func(ctx context.Context) (context.Context, context.CancelFunc) {
addUser := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
newCtx := context.WithValue(ctx, "user", "Alice")
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
adapted := Local[string](addUser)(getValue)

View File

@@ -21,8 +21,9 @@ import (
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/statet"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
SRIOE "github.com/IBM/fp-go/v2/statereaderioeither"
)
// Left creates a StateReaderIOResult that represents a failed computation with the given error.
@@ -202,21 +203,42 @@ func FromResult[S, A any](ma Result[A]) StateReaderIOResult[S, A] {
// Combinators
// Local runs a computation with a modified context.
// The function f transforms the context before passing it to the computation.
// The function f transforms the context before passing it to the computation,
// returning both a new context and a CancelFunc that should be called to release resources.
//
// This is useful for:
// - Adding values to the context
// - Setting timeouts or deadlines
// - Modifying context metadata
//
// The CancelFunc is automatically called after the computation completes to ensure proper cleanup.
//
// Type Parameters:
// - S: The state type
// - A: The result type
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - A Kleisli arrow that takes a StateReaderIOResult[S, A] and returns a StateReaderIOEither[S, R, error, A]
//
// Note: When R is context.Context, the return type simplifies to func(StateReaderIOResult[S, A]) StateReaderIOResult[S, A]
//
// Example:
//
// // Modify context before running computation
// withTimeout := statereaderioresult.Local[AppState](
// func(ctx context.Context) context.Context {
// ctx, _ = context.WithTimeout(ctx, 60*time.Second)
// return ctx
// }
// // Add a timeout to a specific operation
// withTimeout := statereaderioresult.Local[AppState, Data, context.Context](
// func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
// newCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
// return pair.MakePair(cancel, newCtx)
// },
// )
// result := withTimeout(computation)
func Local[S, A any](f func(context.Context) context.Context) func(StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
return func(ma StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
return function.Flow2(ma, RIOR.Local[Pair[S, A]](f))
func Local[S, A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) SRIOE.Kleisli[S, R, error, StateReaderIOResult[S, A], A] {
return func(ma StateReaderIOResult[S, A]) SRIOE.StateReaderIOEither[S, R, error, A] {
return function.Flow2(ma, RIORES.Local[Pair[S, A]](f))
}
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/IBM/fp-go/v2/io"
IOR "github.com/IBM/fp-go/v2/ioresult"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/pair"
P "github.com/IBM/fp-go/v2/pair"
RES "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
@@ -264,8 +265,8 @@ func TestLocal(t *testing.T) {
// Modify context before running computation
result := Local[testState, string](
func(c context.Context) context.Context {
return context.WithValue(c, "key", "value2")
func(c context.Context) ContextCancel {
return pair.MakePair[context.CancelFunc](func() {}, context.WithValue(c, "key", "value2"))
},
)(comp)

View File

@@ -16,6 +16,8 @@
package statereaderioresult
import (
"context"
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/io"
@@ -84,4 +86,11 @@ type (
Operator[S, A, B any] = Reader[StateReaderIOResult[S, A], StateReaderIOResult[S, B]]
Predicate[A any] = predicate.Predicate[A]
// ContextCancel represents a pair of a cancel function and a context.
// It is used in operations that create new contexts with cancellation capabilities.
//
// The first element is the CancelFunc that should be called to release resources.
// The second element is the new Context that was created.
ContextCancel = Pair[context.CancelFunc, context.Context]
)

View File

@@ -25,6 +25,31 @@ import (
"github.com/IBM/fp-go/v2/readerio"
)
// Do creates an Effect with an initial state value.
// This is the starting point for do-notation style effect composition,
// allowing you to build up complex state transformations step by step.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - S: The state type
//
// # Parameters
//
// - empty: The initial state value
//
// # Returns
//
// - Effect[C, S]: An effect that produces the initial state
//
// # Example
//
// type State struct {
// Name string
// Age int
// }
// eff := effect.Do[MyContext](State{})
//
//go:inline
func Do[C, S any](
empty S,
@@ -32,6 +57,40 @@ func Do[C, S any](
return readerreaderioresult.Of[C](empty)
}
// Bind executes an effectful computation and binds its result to the state.
// This is the core operation for do-notation, allowing you to sequence effects
// while accumulating results in a state structure.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - S1: The input state type
// - S2: The output state type
// - T: The type of value produced by the effect
//
// # Parameters
//
// - setter: A function that takes the effect result and returns a state updater
// - f: An effectful computation that depends on the current state
//
// # Returns
//
// - Operator[C, S1, S2]: A function that transforms the state effect
//
// # Example
//
// eff := effect.Bind(
// func(age int) func(State) State {
// return func(s State) State {
// s.Age = age
// return s
// }
// },
// func(s State) Effect[MyContext, int] {
// return effect.Of[MyContext](30)
// },
// )(effect.Do[MyContext](State{}))
//
//go:inline
func Bind[C, S1, S2, T any](
setter func(T) func(S1) S2,
@@ -40,6 +99,39 @@ func Bind[C, S1, S2, T any](
return readerreaderioresult.Bind(setter, f)
}
// Let computes a pure value from the current state and binds it to the state.
// Unlike Bind, this doesn't perform any effects - it's for pure computations.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - S1: The input state type
// - S2: The output state type
// - T: The type of computed value
//
// # Parameters
//
// - setter: A function that takes the computed value and returns a state updater
// - f: A pure function that computes a value from the current state
//
// # Returns
//
// - Operator[C, S1, S2]: A function that transforms the state effect
//
// # Example
//
// eff := effect.Let[MyContext](
// func(nameLen int) func(State) State {
// return func(s State) State {
// s.NameLength = nameLen
// return s
// }
// },
// func(s State) int {
// return len(s.Name)
// },
// )(stateEff)
//
//go:inline
func Let[C, S1, S2, T any](
setter func(T) func(S1) S2,
@@ -48,6 +140,37 @@ func Let[C, S1, S2, T any](
return readerreaderioresult.Let[C](setter, f)
}
// LetTo binds a constant value to the state.
// This is useful for setting fixed values in your state structure.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - S1: The input state type
// - S2: The output state type
// - T: The type of the constant value
//
// # Parameters
//
// - setter: A function that takes the constant and returns a state updater
// - b: The constant value to bind
//
// # Returns
//
// - Operator[C, S1, S2]: A function that transforms the state effect
//
// # Example
//
// eff := effect.LetTo[MyContext](
// func(age int) func(State) State {
// return func(s State) State {
// s.Age = age
// return s
// }
// },
// 42,
// )(stateEff)
//
//go:inline
func LetTo[C, S1, S2, T any](
setter func(T) func(S1) S2,
@@ -56,6 +179,30 @@ func LetTo[C, S1, S2, T any](
return readerreaderioresult.LetTo[C](setter, b)
}
// BindTo wraps a value in an initial state structure.
// This is typically used to start a bind chain by converting a simple value
// into a state structure.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - S1: The state type to create
// - T: The type of the input value
//
// # Parameters
//
// - setter: A function that creates a state from the value
//
// # Returns
//
// - Operator[C, T, S1]: A function that wraps the value in state
//
// # Example
//
// eff := effect.BindTo[MyContext](func(name string) State {
// return State{Name: name}
// })(effect.Of[MyContext]("Alice"))
//
//go:inline
func BindTo[C, S1, T any](
setter func(T) S1,
@@ -63,6 +210,39 @@ func BindTo[C, S1, T any](
return readerreaderioresult.BindTo[C](setter)
}
// ApS applies an effect and binds its result to the state using a setter function.
// This is similar to Bind but takes a pre-existing effect rather than a function
// that creates an effect from the state.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - S1: The input state type
// - S2: The output state type
// - T: The type of value produced by the effect
//
// # Parameters
//
// - setter: A function that takes the effect result and returns a state updater
// - fa: The effect to apply
//
// # Returns
//
// - Operator[C, S1, S2]: A function that transforms the state effect
//
// # Example
//
// ageEffect := effect.Of[MyContext](30)
// eff := effect.ApS(
// func(age int) func(State) State {
// return func(s State) State {
// s.Age = age
// return s
// }
// },
// ageEffect,
// )(stateEff)
//
//go:inline
func ApS[C, S1, S2, T any](
setter func(T) func(S1) S2,
@@ -71,6 +251,33 @@ func ApS[C, S1, S2, T any](
return readerreaderioresult.ApS(setter, fa)
}
// ApSL applies an effect and updates a field in the state using a lens.
// This provides a more ergonomic way to update nested state structures.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - S: The state type
// - T: The type of the field being updated
//
// # Parameters
//
// - lens: A lens focusing on the field to update
// - fa: The effect producing the new field value
//
// # Returns
//
// - Operator[C, S, S]: A function that updates the state field
//
// # Example
//
// ageLens := lens.MakeLens(
// func(s State) int { return s.Age },
// func(s State, age int) State { s.Age = age; return s },
// )
// ageEffect := effect.Of[MyContext](30)
// eff := effect.ApSL(ageLens, ageEffect)(stateEff)
//
//go:inline
func ApSL[C, S, T any](
lens Lens[S, T],
@@ -79,6 +286,37 @@ func ApSL[C, S, T any](
return readerreaderioresult.ApSL(lens, fa)
}
// BindL executes an effectful computation on a field and updates it using a lens.
// The effect function receives the current field value and produces a new value.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - S: The state type
// - T: The type of the field being updated
//
// # Parameters
//
// - lens: A lens focusing on the field to update
// - f: An effectful function that transforms the field value
//
// # Returns
//
// - Operator[C, S, S]: A function that updates the state field
//
// # Example
//
// ageLens := lens.MakeLens(
// func(s State) int { return s.Age },
// func(s State, age int) State { s.Age = age; return s },
// )
// eff := effect.BindL(
// ageLens,
// func(age int) Effect[MyContext, int] {
// return effect.Of[MyContext](age + 1)
// },
// )(stateEff)
//
//go:inline
func BindL[C, S, T any](
lens Lens[S, T],
@@ -87,6 +325,35 @@ func BindL[C, S, T any](
return readerreaderioresult.BindL(lens, f)
}
// LetL computes a new field value from the current value using a lens.
// This is a pure transformation of a field within the state.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - S: The state type
// - T: The type of the field being updated
//
// # Parameters
//
// - lens: A lens focusing on the field to update
// - f: A pure function that transforms the field value
//
// # Returns
//
// - Operator[C, S, S]: A function that updates the state field
//
// # Example
//
// ageLens := lens.MakeLens(
// func(s State) int { return s.Age },
// func(s State, age int) State { s.Age = age; return s },
// )
// eff := effect.LetL[MyContext](
// ageLens,
// func(age int) int { return age * 2 },
// )(stateEff)
//
//go:inline
func LetL[C, S, T any](
lens Lens[S, T],
@@ -95,6 +362,31 @@ func LetL[C, S, T any](
return readerreaderioresult.LetL[C](lens, f)
}
// LetToL sets a field to a constant value using a lens.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - S: The state type
// - T: The type of the field being updated
//
// # Parameters
//
// - lens: A lens focusing on the field to update
// - b: The constant value to set
//
// # Returns
//
// - Operator[C, S, S]: A function that updates the state field
//
// # Example
//
// ageLens := lens.MakeLens(
// func(s State) int { return s.Age },
// func(s State, age int) State { s.Age = age; return s },
// )
// eff := effect.LetToL[MyContext](ageLens, 42)(stateEff)
//
//go:inline
func LetToL[C, S, T any](
lens Lens[S, T],

32
v2/effect/common_test.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"context"
)
// TestContext is a common test context type used across effect tests
type TestContext struct {
Value string
}
// runEffect is a helper function to run an effect with a context and return the result
func runEffect[C, A any](eff Effect[C, A], ctx C) (A, error) {
ioResult := Provide[A](ctx)(eff)
readerResult := RunSync(ioResult)
return readerResult(context.Background())
}

View File

@@ -1,6 +1,22 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"github.com/IBM/fp-go/v2/context/reader"
thunk "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
"github.com/IBM/fp-go/v2/io"
@@ -8,31 +24,182 @@ import (
"github.com/IBM/fp-go/v2/result"
)
// Local transforms the context required by an effect using a pure function.
// This allows you to adapt an effect that requires one context type to work
// with a different context type by providing a transformation function.
//
// # Type Parameters
//
// - C1: The outer context type (what you have)
// - C2: The inner context type (what the effect needs)
// - A: The value type produced by the effect
//
// # Parameters
//
// - acc: A pure function that transforms C1 to C2
//
// # Returns
//
// - Kleisli[C1, Effect[C2, A], A]: A function that adapts the effect to use C1
//
// # Example
//
// type AppConfig struct { DB DatabaseConfig }
// type DatabaseConfig struct { Host string }
// dbEffect := effect.Of[DatabaseConfig]("connected")
// appEffect := effect.Local[AppConfig, DatabaseConfig, string](
// func(app AppConfig) DatabaseConfig { return app.DB },
// )(dbEffect)
//
//go:inline
func Local[C1, C2, A any](acc Reader[C1, C2]) Kleisli[C1, Effect[C2, A], A] {
func Local[A, C1, C2 any](acc Reader[C1, C2]) Kleisli[C1, Effect[C2, A], A] {
return readerreaderioresult.Local[A](acc)
}
// Contramap is an alias for Local, following the contravariant functor naming convention.
// It transforms the context required by an effect using a pure function.
//
// # Type Parameters
//
// - C1: The outer context type (what you have)
// - C2: The inner context type (what the effect needs)
// - A: The value type produced by the effect
//
// # Parameters
//
// - acc: A pure function that transforms C1 to C2
//
// # Returns
//
// - Kleisli[C1, Effect[C2, A], A]: A function that adapts the effect to use C1
//
//go:inline
func Contramap[C1, C2, A any](acc Reader[C1, C2]) Kleisli[C1, Effect[C2, A], A] {
func Contramap[A, C1, C2 any](acc Reader[C1, C2]) Kleisli[C1, Effect[C2, A], A] {
return readerreaderioresult.Local[A](acc)
}
// LocalIOK transforms the context using an IO-based function.
// This allows the context transformation itself to perform I/O operations.
//
// # Type Parameters
//
// - A: The value type produced by the effect
// - C1: The inner context type (what the effect needs)
// - C2: The outer context type (what you have)
//
// # Parameters
//
// - f: An IO function that transforms C2 to C1
//
// # Returns
//
// - func(Effect[C1, A]) Effect[C2, A]: A function that adapts the effect
//
// # Example
//
// loadConfig := func(path string) io.IO[Config] {
// return func() Config { /* load from file */ }
// }
// transform := effect.LocalIOK[string](loadConfig)
// adapted := transform(configEffect)
//
//go:inline
func LocalIOK[A, C1, C2 any](f io.Kleisli[C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalIOK[A](f)
}
// LocalIOResultK transforms the context using an IOResult-based function.
// This allows the context transformation to perform I/O and handle errors.
//
// # Type Parameters
//
// - A: The value type produced by the effect
// - C1: The inner context type (what the effect needs)
// - C2: The outer context type (what you have)
//
// # Parameters
//
// - f: An IOResult function that transforms C2 to C1
//
// # Returns
//
// - func(Effect[C1, A]) Effect[C2, A]: A function that adapts the effect
//
// # Example
//
// loadConfig := func(path string) ioresult.IOResult[Config] {
// return func() result.Result[Config] {
// // load from file, may fail
// }
// }
// transform := effect.LocalIOResultK[string](loadConfig)
// adapted := transform(configEffect)
//
//go:inline
func LocalIOResultK[A, C1, C2 any](f ioresult.Kleisli[C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalIOResultK[A](f)
}
// LocalResultK transforms the context using a Result-based function.
// This allows the context transformation to fail with an error.
//
// # Type Parameters
//
// - A: The value type produced by the effect
// - C1: The inner context type (what the effect needs)
// - C2: The outer context type (what you have)
//
// # Parameters
//
// - f: A Result function that transforms C2 to C1
//
// # Returns
//
// - func(Effect[C1, A]) Effect[C2, A]: A function that adapts the effect
//
// # Example
//
// validateConfig := func(raw RawConfig) result.Result[Config] {
// if raw.IsValid() {
// return result.Of(raw.ToConfig())
// }
// return result.Left[Config](errors.New("invalid"))
// }
// transform := effect.LocalResultK[string](validateConfig)
// adapted := transform(configEffect)
//
//go:inline
func LocalResultK[A, C1, C2 any](f result.Kleisli[C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalResultK[A](f)
}
// LocalThunkK transforms the context using a Thunk (ReaderIOResult) function.
// This allows the context transformation to depend on context.Context, perform I/O, and handle errors.
//
// # Type Parameters
//
// - A: The value type produced by the effect
// - C1: The inner context type (what the effect needs)
// - C2: The outer context type (what you have)
//
// # Parameters
//
// - f: A Thunk function that transforms C2 to C1
//
// # Returns
//
// - func(Effect[C1, A]) Effect[C2, A]: A function that adapts the effect
//
// # Example
//
// loadConfig := func(path string) readerioresult.ReaderIOResult[Config] {
// return func(ctx context.Context) ioresult.IOResult[Config] {
// // load from file with context, may fail
// }
// }
// transform := effect.LocalThunkK[string](loadConfig)
// adapted := transform(configEffect)
//
//go:inline
func LocalThunkK[A, C1, C2 any](f thunk.Kleisli[C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalReaderIOResultK[A](f)
@@ -101,10 +268,106 @@ func LocalThunkK[A, C1, C2 any](f thunk.Kleisli[C2, C1]) func(Effect[C1, A]) Eff
// - Local/Contramap: Pure context transformation (C2 -> C1)
// - LocalIOK: IO-based transformation (C2 -> IO[C1])
// - LocalIOResultK: IO with error handling (C2 -> IOResult[C1])
// - LocalReaderIOResultK: Reader-based with IO and errors (C2 -> ReaderIOResult[C1])
// - LocalThunkK: Reader-based with IO and errors (C2 -> ReaderIOResult[C1])
// - LocalEffectK: Full Effect transformation (C2 -> Effect[C2, C1])
//
//go:inline
func LocalEffectK[A, C1, C2 any](f Kleisli[C2, C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalReaderReaderIOEitherK[A](f)
}
// LocalReaderK transforms the context of an Effect using a Reader-based Kleisli arrow.
// It allows you to modify the context through a pure computation that depends on the runtime context
// before passing it to the Effect.
//
// This is useful when the context transformation is a pure computation that requires access
// to the runtime context (context.Context) but cannot fail. Common use cases include:
// - Extracting configuration from context values
// - Computing derived context values based on runtime context
// - Transforming context based on runtime metadata
//
// The transformation happens in two stages:
// 1. The Reader function f is executed with the C2 context and runtime context to produce a C1 value
// 2. The resulting C1 value is passed as the context to the Effect[C1, A]
//
// # Type Parameters
//
// - A: The value type produced by the effect
// - C1: The inner context type (required by the original effect)
// - C2: The outer context type (provided to the transformed effect)
//
// # Parameters
//
// - f: A Reader Kleisli arrow that transforms C2 to C1 using the runtime context
//
// # Returns
//
// - func(Effect[C1, A]) Effect[C2, A]: A function that adapts the effect to use C2
//
// # Example
//
// type ctxKey string
// const configKey ctxKey = "config"
//
// type DetailedConfig struct {
// Host string
// Port int
// }
//
// type SimpleConfig struct {
// Port int
// }
//
// // Extract config from runtime context and transform
// extractConfig := func(path string) reader.Reader[DetailedConfig] {
// return func(ctx context.Context) DetailedConfig {
// if cfg, ok := ctx.Value(configKey).(DetailedConfig); ok {
// return cfg
// }
// return DetailedConfig{Host: "localhost", Port: 8080}
// }
// }
//
// // Effect that uses DetailedConfig
// configEffect := effect.Of[DetailedConfig]("connected")
//
// // Transform to use string path instead
// transform := effect.LocalReaderK[string](extractConfig)
// pathEffect := transform(configEffect)
//
// // Run with runtime context containing config
// ctx := context.WithValue(context.Background(), configKey, DetailedConfig{Host: "api.example.com", Port: 443})
// ioResult := effect.Provide[string]("config.json")(pathEffect)
// readerResult := effect.RunSync(ioResult)
// result, err := readerResult(ctx) // Uses config from context
//
// # Comparison with other Local functions
//
// - Local/Contramap: Pure context transformation (C2 -> C1)
// - LocalIOK: IO-based transformation (C2 -> IO[C1])
// - LocalIOResultK: IO with error handling (C2 -> IOResult[C1])
// - LocalReaderK: Reader-based pure transformation with runtime context access (C2 -> Reader[C1])
// - LocalThunkK: Reader-based with IO and errors (C2 -> ReaderIOResult[C1])
// - LocalEffectK: Full Effect transformation (C2 -> Effect[C2, C1])
//
//go:inline
func LocalReaderK[A, C1, C2 any](f reader.Kleisli[C2, C1]) func(Effect[C1, A]) Effect[C2, A] {
return readerreaderioresult.LocalReaderK[A](f)
}
// Ask returns an Effect that produces the context C as its success value.
// This is the fundamental operation of the reader/environment monad,
// allowing effects to access their own context.
//
// # Type Parameters
//
// - C: The context type (also the produced value type)
//
// # Returns
//
// - Effect[C, C]: An effect that succeeds with its own context value
//
//go:inline
func Ask[C any]() Effect[C, C] {
return readerreaderioresult.Ask[C]()
}

View File

@@ -19,7 +19,7 @@ import (
"context"
"fmt"
"testing"
"github.com/IBM/fp-go/v2/context/reader"
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
"github.com/stretchr/testify/assert"
)
@@ -44,11 +44,11 @@ func TestLocal(t *testing.T) {
}
// Apply Local to transform the context
kleisli := Local[OuterContext, InnerContext, string](accessor)
kleisli := Local[string](accessor)
outerEffect := kleisli(innerEffect)
// Run with OuterContext
ioResult := Provide[OuterContext, string](OuterContext{
ioResult := Provide[string](OuterContext{
Value: "test",
Number: 42,
})(outerEffect)
@@ -70,11 +70,11 @@ func TestLocal(t *testing.T) {
return InnerContext{Value: outer.Value + " transformed"}
}
kleisli := Local[OuterContext, InnerContext, string](accessor)
kleisli := Local[string](accessor)
outerEffect := kleisli(innerEffect)
// Run with OuterContext
ioResult := Provide[OuterContext, string](OuterContext{
ioResult := Provide[string](OuterContext{
Value: "original",
Number: 100,
})(outerEffect)
@@ -93,10 +93,10 @@ func TestLocal(t *testing.T) {
return InnerContext{Value: outer.Value}
}
kleisli := Local[OuterContext, InnerContext, string](accessor)
kleisli := Local[string](accessor)
outerEffect := kleisli(innerEffect)
ioResult := Provide[OuterContext, string](OuterContext{
ioResult := Provide[string](OuterContext{
Value: "test",
Number: 42,
})(outerEffect)
@@ -122,12 +122,12 @@ func TestLocal(t *testing.T) {
level3Effect := Of[Level3]("deep result")
// Transform Level2 -> Level3
local23 := Local[Level2, Level3, string](func(l2 Level2) Level3 {
local23 := Local[string](func(l2 Level2) Level3 {
return Level3{C: l2.B + "-c"}
})
// Transform Level1 -> Level2
local12 := Local[Level1, Level2, string](func(l1 Level1) Level2 {
local12 := Local[string](func(l1 Level1) Level2 {
return Level2{B: l1.A + "-b"}
})
@@ -136,7 +136,7 @@ func TestLocal(t *testing.T) {
level1Effect := local12(level2Effect)
// Run with Level1 context
ioResult := Provide[Level1, string](Level1{A: "a"})(level1Effect)
ioResult := Provide[string](Level1{A: "a"})(level1Effect)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
@@ -165,11 +165,11 @@ func TestLocal(t *testing.T) {
return app.DB
}
kleisli := Local[AppConfig, DatabaseConfig, string](accessor)
kleisli := Local[string](accessor)
appEffect := kleisli(dbEffect)
// Run with full AppConfig
ioResult := Provide[AppConfig, string](AppConfig{
ioResult := Provide[string](AppConfig{
DB: DatabaseConfig{
Host: "localhost",
Port: 5432,
@@ -195,21 +195,21 @@ func TestContramap(t *testing.T) {
}
// Test Local
localKleisli := Local[OuterContext, InnerContext, int](accessor)
localKleisli := Local[int](accessor)
localEffect := localKleisli(innerEffect)
// Test Contramap
contramapKleisli := Contramap[OuterContext, InnerContext, int](accessor)
contramapKleisli := Contramap[int](accessor)
contramapEffect := contramapKleisli(innerEffect)
outerCtx := OuterContext{Value: "test", Number: 100}
// Run both
localIO := Provide[OuterContext, int](outerCtx)(localEffect)
localIO := Provide[int](outerCtx)(localEffect)
localReader := RunSync(localIO)
localResult, localErr := localReader(context.Background())
contramapIO := Provide[OuterContext, int](outerCtx)(contramapEffect)
contramapIO := Provide[int](outerCtx)(contramapEffect)
contramapReader := RunSync(contramapIO)
contramapResult, contramapErr := contramapReader(context.Background())
@@ -225,10 +225,10 @@ func TestContramap(t *testing.T) {
return InnerContext{Value: outer.Value + " modified"}
}
kleisli := Contramap[OuterContext, InnerContext, string](accessor)
kleisli := Contramap[string](accessor)
outerEffect := kleisli(innerEffect)
ioResult := Provide[OuterContext, string](OuterContext{
ioResult := Provide[string](OuterContext{
Value: "original",
Number: 50,
})(outerEffect)
@@ -247,10 +247,10 @@ func TestContramap(t *testing.T) {
return InnerContext{Value: outer.Value}
}
kleisli := Contramap[OuterContext, InnerContext, int](accessor)
kleisli := Contramap[int](accessor)
outerEffect := kleisli(innerEffect)
ioResult := Provide[OuterContext, int](OuterContext{
ioResult := Provide[int](OuterContext{
Value: "test",
Number: 42,
})(outerEffect)
@@ -278,12 +278,12 @@ func TestLocalAndContramapInteroperability(t *testing.T) {
effect3 := Of[Config3]("result")
// Use Local for first transformation
local23 := Local[Config2, Config3, string](func(c2 Config2) Config3 {
local23 := Local[string](func(c2 Config2) Config3 {
return Config3{Info: c2.Data}
})
// Use Contramap for second transformation
contramap12 := Contramap[Config1, Config2, string](func(c1 Config1) Config2 {
contramap12 := Contramap[string](func(c1 Config1) Config2 {
return Config2{Data: c1.Value}
})
@@ -292,7 +292,7 @@ func TestLocalAndContramapInteroperability(t *testing.T) {
effect1 := contramap12(effect2)
// Run
ioResult := Provide[Config1, string](Config1{Value: "test"})(effect1)
ioResult := Provide[string](Config1{Value: "test"})(effect1)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
@@ -326,7 +326,7 @@ func TestLocalEffectK(t *testing.T) {
appEffect := transform(dbEffect)
// Run with AppConfig
ioResult := Provide[AppConfig, string](AppConfig{
ioResult := Provide[string](AppConfig{
ConfigPath: "/etc/app.conf",
})(appEffect)
readerResult := RunSync(ioResult)
@@ -356,7 +356,7 @@ func TestLocalEffectK(t *testing.T) {
transform := LocalEffectK[string](failingTransform)
outerEffect := transform(innerEffect)
ioResult := Provide[OuterCtx, string](OuterCtx{Path: "test"})(outerEffect)
ioResult := Provide[string](OuterCtx{Path: "test"})(outerEffect)
readerResult := RunSync(ioResult)
_, err := readerResult(context.Background())
@@ -384,7 +384,7 @@ func TestLocalEffectK(t *testing.T) {
transformK := LocalEffectK[string](transform)
outerEffect := transformK(innerEffect)
ioResult := Provide[OuterCtx, string](OuterCtx{Path: "test"})(outerEffect)
ioResult := Provide[string](OuterCtx{Path: "test"})(outerEffect)
readerResult := RunSync(ioResult)
_, err := readerResult(context.Background())
@@ -417,7 +417,7 @@ func TestLocalEffectK(t *testing.T) {
transform := LocalEffectK[string](loadConfigEffect)
appEffect := transform(configEffect)
ioResult := Provide[AppContext, string](AppContext{
ioResult := Provide[string](AppContext{
ConfigFile: "config.json",
})(appEffect)
readerResult := RunSync(ioResult)
@@ -456,7 +456,7 @@ func TestLocalEffectK(t *testing.T) {
level1Effect := transform12(level2Effect)
// Run with Level1 context
ioResult := Provide[Level1, string](Level1{A: "a"})(level1Effect)
ioResult := Provide[string](Level1{A: "a"})(level1Effect)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
@@ -497,7 +497,7 @@ func TestLocalEffectK(t *testing.T) {
transform := LocalEffectK[string](transformWithContext)
appEffect := transform(dbEffect)
ioResult := Provide[AppConfig, string](AppConfig{
ioResult := Provide[string](AppConfig{
Environment: "prod",
DBHost: "localhost",
DBPort: 5432,
@@ -534,14 +534,14 @@ func TestLocalEffectK(t *testing.T) {
outerEffect := transform(innerEffect)
// Test with invalid config
ioResult := Provide[RawConfig, string](RawConfig{APIKey: ""})(outerEffect)
ioResult := Provide[string](RawConfig{APIKey: ""})(outerEffect)
readerResult := RunSync(ioResult)
_, err := readerResult(context.Background())
assert.Error(t, err)
// Test with valid config
ioResult2 := Provide[RawConfig, string](RawConfig{APIKey: "valid-key"})(outerEffect)
ioResult2 := Provide[string](RawConfig{APIKey: "valid-key"})(outerEffect)
readerResult2 := RunSync(ioResult2)
result, err2 := readerResult2(context.Background())
@@ -569,7 +569,7 @@ func TestLocalEffectK(t *testing.T) {
})
// Use Local for second transformation (pure)
local12 := Local[Level1, Level2, string](func(l1 Level1) Level2 {
local12 := Local[string](func(l1 Level1) Level2 {
return Level2{Data: l1.Value}
})
@@ -578,7 +578,7 @@ func TestLocalEffectK(t *testing.T) {
effect1 := local12(effect2)
// Run
ioResult := Provide[Level1, string](Level1{Value: "test"})(effect1)
ioResult := Provide[string](Level1{Value: "test"})(effect1)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
@@ -610,7 +610,7 @@ func TestLocalEffectK(t *testing.T) {
transform := LocalEffectK[int](complexTransform)
outerEffect := transform(innerEffect)
ioResult := Provide[OuterCtx, int](OuterCtx{Multiplier: 3})(outerEffect)
ioResult := Provide[int](OuterCtx{Multiplier: 3})(outerEffect)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
@@ -618,3 +618,379 @@ func TestLocalEffectK(t *testing.T) {
assert.Equal(t, 60, result) // 3 * 10 * 2
})
}
func TestLocalReaderK(t *testing.T) {
t.Run("basic Reader transformation", func(t *testing.T) {
type SimpleConfig struct {
Port int
}
// Reader that transforms string path to SimpleConfig using runtime context
loadConfig := func(path string) reader.Reader[SimpleConfig] {
return func(ctx context.Context) SimpleConfig {
// Could extract values from runtime context here
return SimpleConfig{Port: 8080}
}
}
// Effect that uses the config
configEffect := Of[SimpleConfig]("connected")
// Transform using LocalReaderK
transform := LocalReaderK[string](loadConfig)
pathEffect := transform(configEffect)
// Run with path
ioResult := Provide[string]("config.json")(pathEffect)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
assert.NoError(t, err)
assert.Equal(t, "connected", result)
})
t.Run("extract config from runtime context", func(t *testing.T) {
type ctxKey string
const configKey ctxKey = "config"
type DetailedConfig struct {
Host string
Port int
}
// Reader that extracts config from runtime context
extractConfig := func(path string) reader.Reader[DetailedConfig] {
return func(ctx context.Context) DetailedConfig {
if cfg, ok := ctx.Value(configKey).(DetailedConfig); ok {
return cfg
}
// Default config if not in runtime context
return DetailedConfig{Host: "localhost", Port: 8080}
}
}
// Effect that uses the config
configEffect := Chain(func(cfg DetailedConfig) Effect[DetailedConfig, string] {
return Of[DetailedConfig](fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
})(readerreaderioresult.Ask[DetailedConfig]())
transform := LocalReaderK[string](extractConfig)
pathEffect := transform(configEffect)
// With config in runtime context
ctxWithConfig := context.WithValue(context.Background(), configKey, DetailedConfig{Host: "api.example.com", Port: 443})
ioResult := Provide[string]("ignored")(pathEffect)
readerResult := RunSync(ioResult)
result, err := readerResult(ctxWithConfig)
assert.NoError(t, err)
assert.Equal(t, "api.example.com:443", result)
// Without config in runtime context (uses default)
ioResult2 := Provide[string]("ignored")(pathEffect)
readerResult2 := RunSync(ioResult2)
result2, err2 := readerResult2(context.Background())
assert.NoError(t, err2)
assert.Equal(t, "localhost:8080", result2)
})
t.Run("runtime context-aware transformation", func(t *testing.T) {
type ctxKey string
const multiplierKey ctxKey = "multiplier"
// Reader that uses runtime context to compute context
computeValue := func(base int) reader.Reader[int] {
return func(ctx context.Context) int {
if mult, ok := ctx.Value(multiplierKey).(int); ok {
return base * mult
}
return base
}
}
// Effect that uses the computed value
valueEffect := Chain(func(val int) Effect[int, string] {
return Of[int](fmt.Sprintf("Value: %d", val))
})(readerreaderioresult.Ask[int]())
transform := LocalReaderK[string](computeValue)
baseEffect := transform(valueEffect)
// With multiplier in runtime context
ctxWithMult := context.WithValue(context.Background(), multiplierKey, 10)
ioResult := Provide[string](5)(baseEffect)
readerResult := RunSync(ioResult)
result, err := readerResult(ctxWithMult)
assert.NoError(t, err)
assert.Equal(t, "Value: 50", result)
// Without multiplier (uses base value)
ioResult2 := Provide[string](5)(baseEffect)
readerResult2 := RunSync(ioResult2)
result2, err2 := readerResult2(context.Background())
assert.NoError(t, err2)
assert.Equal(t, "Value: 5", result2)
})
t.Run("compose multiple LocalReaderK", func(t *testing.T) {
type ctxKey string
const prefixKey ctxKey = "prefix"
// First transformation: int -> string using runtime context
intToString := func(n int) reader.Reader[string] {
return func(ctx context.Context) string {
if prefix, ok := ctx.Value(prefixKey).(string); ok {
return fmt.Sprintf("%s-%d", prefix, n)
}
return fmt.Sprintf("%d", n)
}
}
// Second transformation: string -> SimpleConfig
type SimpleConfig struct {
Port int
}
stringToConfig := func(s string) reader.Reader[SimpleConfig] {
return func(ctx context.Context) SimpleConfig {
return SimpleConfig{Port: len(s) * 100}
}
}
// Effect that uses the config
configEffect := Chain(func(cfg SimpleConfig) Effect[SimpleConfig, string] {
return Of[SimpleConfig](fmt.Sprintf("Port: %d", cfg.Port))
})(readerreaderioresult.Ask[SimpleConfig]())
// Compose transformations
step1 := LocalReaderK[string](stringToConfig)
step2 := LocalReaderK[string](intToString)
effect1 := step1(configEffect)
effect2 := step2(effect1)
// With prefix in runtime context
ctxWithPrefix := context.WithValue(context.Background(), prefixKey, "test")
ioResult := Provide[string](42)(effect2)
readerResult := RunSync(ioResult)
result, err := readerResult(ctxWithPrefix)
assert.NoError(t, err)
// "test-42" has length 7, so port = 700
assert.Equal(t, "Port: 700", result)
// Without prefix
ioResult2 := Provide[string](42)(effect2)
readerResult2 := RunSync(ioResult2)
result2, err2 := readerResult2(context.Background())
assert.NoError(t, err2)
// "42" has length 2, so port = 200
assert.Equal(t, "Port: 200", result2)
})
t.Run("error propagation from Effect", func(t *testing.T) {
type SimpleConfig struct {
Port int
}
// Reader transformation (pure, cannot fail)
loadConfig := func(path string) reader.Reader[SimpleConfig] {
return func(ctx context.Context) SimpleConfig {
return SimpleConfig{Port: 8080}
}
}
// Effect that returns an error
expectedErr := assert.AnError
failingEffect := Fail[SimpleConfig, string](expectedErr)
transform := LocalReaderK[string](loadConfig)
pathEffect := transform(failingEffect)
ioResult := Provide[string]("config.json")(pathEffect)
readerResult := RunSync(ioResult)
_, err := readerResult(context.Background())
// Error from the Effect should propagate
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("real-world: environment selection based on runtime context", func(t *testing.T) {
type Environment string
const (
Dev Environment = "dev"
Prod Environment = "prod"
)
type ctxKey string
const envKey ctxKey = "environment"
type EnvConfig struct {
Name string
}
type DetailedConfig struct {
Host string
Port int
}
// Reader that selects config based on runtime context environment
selectConfig := func(envName EnvConfig) reader.Reader[DetailedConfig] {
return func(ctx context.Context) DetailedConfig {
env := Dev
if e, ok := ctx.Value(envKey).(Environment); ok {
env = e
}
switch env {
case Prod:
return DetailedConfig{Host: "api.production.com", Port: 443}
default:
return DetailedConfig{Host: "localhost", Port: 8080}
}
}
}
// Effect that uses the selected config
configEffect := Chain(func(cfg DetailedConfig) Effect[DetailedConfig, string] {
return Of[DetailedConfig](fmt.Sprintf("Connecting to %s:%d", cfg.Host, cfg.Port))
})(readerreaderioresult.Ask[DetailedConfig]())
transform := LocalReaderK[string](selectConfig)
envEffect := transform(configEffect)
// Production environment
ctxProd := context.WithValue(context.Background(), envKey, Prod)
ioResult := Provide[string](EnvConfig{Name: "app"})(envEffect)
readerResult := RunSync(ioResult)
result, err := readerResult(ctxProd)
assert.NoError(t, err)
assert.Equal(t, "Connecting to api.production.com:443", result)
// Development environment (default)
ioResult2 := Provide[string](EnvConfig{Name: "app"})(envEffect)
readerResult2 := RunSync(ioResult2)
result2, err2 := readerResult2(context.Background())
assert.NoError(t, err2)
assert.Equal(t, "Connecting to localhost:8080", result2)
})
t.Run("composes with other Local functions", func(t *testing.T) {
type Level1 struct {
Value string
}
type Level2 struct {
Data string
}
type Level3 struct {
Info string
}
// Effect at deepest level
effect3 := Of[Level3]("result")
// Use LocalReaderK for first transformation (with runtime context access)
localReaderK23 := LocalReaderK[string](func(l2 Level2) reader.Reader[Level3] {
return func(ctx context.Context) Level3 {
return Level3{Info: l2.Data}
}
})
// Use Local for second transformation (pure)
local12 := Local[string](func(l1 Level1) Level2 {
return Level2{Data: l1.Value}
})
// Compose them
effect2 := localReaderK23(effect3)
effect1 := local12(effect2)
// Run
ioResult := Provide[string](Level1{Value: "test"})(effect1)
readerResult := RunSync(ioResult)
result, err := readerResult(context.Background())
assert.NoError(t, err)
assert.Equal(t, "result", result)
})
}
func TestAsk(t *testing.T) {
t.Run("returns context as value", func(t *testing.T) {
ctx := "my-context"
result, err := runEffect(Ask[string](), ctx)
assert.NoError(t, err)
assert.Equal(t, ctx, result)
})
t.Run("works with struct context", func(t *testing.T) {
type Config struct {
Host string
Port int
}
cfg := Config{Host: "localhost", Port: 8080}
result, err := runEffect(Ask[Config](), cfg)
assert.NoError(t, err)
assert.Equal(t, cfg, result)
})
t.Run("can be chained with Map to extract a field", func(t *testing.T) {
type Config struct {
Host string
Port int
}
hostEffect := Map[Config](func(cfg Config) string {
return cfg.Host
})(Ask[Config]())
result, err := runEffect(hostEffect, Config{Host: "example.com", Port: 443})
assert.NoError(t, err)
assert.Equal(t, "example.com", result)
})
t.Run("can be chained with Chain to produce a derived effect", func(t *testing.T) {
type Config struct {
APIKey string
}
derived := Chain(func(cfg Config) Effect[Config, string] {
if cfg.APIKey == "" {
return Fail[Config, string](assert.AnError)
}
return Of[Config]("authenticated: " + cfg.APIKey)
})(Ask[Config]())
// Valid key
result, err := runEffect(derived, Config{APIKey: "secret"})
assert.NoError(t, err)
assert.Equal(t, "authenticated: secret", result)
// Empty key
_, err = runEffect(derived, Config{APIKey: ""})
assert.Error(t, err)
assert.Equal(t, assert.AnError, err)
})
t.Run("is idempotent - multiple calls return same context", func(t *testing.T) {
ctx := TestContext{Value: "shared"}
r1, err1 := runEffect(Ask[TestContext](), ctx)
r2, err2 := runEffect(Ask[TestContext](), ctx)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, r1, r2)
})
}

View File

@@ -1,51 +1,759 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
thunk "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/fromreader"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/result"
)
// FromThunk lifts a Thunk (context-independent IO computation with error handling) into an Effect.
// This allows you to integrate computations that don't need the effect's context type C
// into effect chains. The Thunk will be executed with the runtime context when the effect runs.
//
// # Type Parameters
//
// - C: The context type required by the effect (not used by the thunk)
// - A: The type of the success value
//
// # Parameters
//
// - f: A Thunk[A] that performs IO with error handling
//
// # Returns
//
// - Effect[C, A]: An effect that ignores its context and executes the thunk
//
// # Example
//
// thunk := func(ctx context.Context) io.IO[result.Result[int]] {
// return func() result.Result[int] {
// // Perform IO operation
// return result.Of(42)
// }
// }
//
// eff := effect.FromThunk[MyContext](thunk)
// // eff can be used in any context but executes the thunk
//
//go:inline
func FromThunk[C, A any](f Thunk[A]) Effect[C, A] {
return reader.Of[C](f)
}
//go:inline
func FromResult[C, A any](r Result[A]) Effect[C, A] {
return readerreaderioresult.FromEither[C](r)
}
// Succeed creates a successful Effect that produces the given value.
// This is the primary way to lift a pure value into the Effect context.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The type of the success value
//
// # Parameters
//
// - a: The value to wrap in a successful effect
//
// # Returns
//
// - Effect[C, A]: An effect that always succeeds with the given value
//
// # Example
//
// eff := effect.Succeed[MyContext](42)
// result, err := runEffect(eff, myContext)
// // result == 42, err == nil
func Succeed[C, A any](a A) Effect[C, A] {
return readerreaderioresult.Of[C](a)
}
// Fail creates a failed Effect with the given error.
// This is used to represent computations that have failed.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The type of the success value (never produced)
//
// # Parameters
//
// - err: The error that caused the failure
//
// # Returns
//
// - Effect[C, A]: An effect that always fails with the given error
//
// # Example
//
// eff := effect.Fail[MyContext, int](errors.New("failed"))
// _, err := runEffect(eff, myContext)
// // err == errors.New("failed")
func Fail[C, A any](err error) Effect[C, A] {
return readerreaderioresult.Left[C, A](err)
}
// Of creates a successful Effect that produces the given value.
// This is an alias for Succeed and follows the pointed functor convention.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The type of the success value
//
// # Parameters
//
// - a: The value to wrap in a successful effect
//
// # Returns
//
// - Effect[C, A]: An effect that always succeeds with the given value
//
// # Example
//
// eff := effect.Of[MyContext]("hello")
// result, err := runEffect(eff, myContext)
// // result == "hello", err == nil
func Of[C, A any](a A) Effect[C, A] {
return readerreaderioresult.Of[C](a)
}
// Map transforms the success value of an Effect using the provided function.
// If the effect fails, the error is propagated unchanged.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: The transformation function to apply to the success value
//
// # Returns
//
// - Operator[C, A, B]: A function that transforms Effect[C, A] to Effect[C, B]
//
// # Example
//
// eff := effect.Of[MyContext](42)
// mapped := effect.Map[MyContext](func(x int) string {
// return strconv.Itoa(x)
// })(eff)
// // mapped produces "42"
func Map[C, A, B any](f func(A) B) Operator[C, A, B] {
return readerreaderioresult.Map[C](f)
}
// Chain sequences two effects, where the second effect depends on the result of the first.
// This is the monadic bind operation (flatMap) for effects.
// If the first effect fails, the second is not executed.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: A function that takes the result of the first effect and returns a new effect
//
// # Returns
//
// - Operator[C, A, B]: A function that transforms Effect[C, A] to Effect[C, B]
//
// # Example
//
// eff := effect.Of[MyContext](42)
// chained := effect.Chain[MyContext](func(x int) Effect[MyContext, string] {
// return effect.Of[MyContext](strconv.Itoa(x * 2))
// })(eff)
// // chained produces "84"
//
//go:inline
func Chain[C, A, B any](f Kleisli[C, A, B]) Operator[C, A, B] {
return readerreaderioresult.Chain(f)
}
//go:inline
func ChainFirst[C, A, B any](f Kleisli[C, A, B]) Operator[C, A, A] {
return readerreaderioresult.ChainFirst(f)
}
// ChainFirstThunkK chains an effect with a function that returns a Thunk,
// but discards the result and returns the original value.
// This is useful for performing side effects (like logging or IO operations) that don't
// need the effect's context, without changing the value flowing through the computation.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The value type (preserved)
// - B: The type produced by the Thunk (discarded)
//
// # Parameters
//
// - f: A function that takes A and returns Thunk[B] for side effects
//
// # Returns
//
// - Operator[C, A, A]: A function that executes the Thunk but preserves the original value
//
// # Example
//
// logToFile := func(n int) readerioresult.ReaderIOResult[any] {
// return func(ctx context.Context) io.IO[result.Result[any]] {
// return func() result.Result[any] {
// // Perform IO operation that doesn't need effect context
// fmt.Printf("Logging: %d\n", n)
// return result.Of[any](nil)
// }
// }
// }
//
// eff := effect.Of[MyContext](42)
// logged := effect.ChainFirstThunkK[MyContext](logToFile)(eff)
// // Prints "Logging: 42" but still produces 42
//
// # See Also
//
// - ChainThunkK: Chains with a Thunk and uses its result
// - TapThunkK: Alias for ChainFirstThunkK
// - ChainFirstIOK: Similar but for IO operations
//
//go:inline
func ChainFirstThunkK[C, A, B any](f thunk.Kleisli[A, B]) Operator[C, A, A] {
return fromreader.ChainFirstReaderK(
ChainFirst[C, A, B],
FromThunk[C, B],
f,
)
}
// TapThunkK is an alias for ChainFirstThunkK.
// It chains an effect with a function that returns a Thunk for side effects,
// but preserves the original value. This is useful for logging, debugging, or
// performing IO operations that don't need the effect's context.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The value type (preserved)
// - B: The type produced by the Thunk (discarded)
//
// # Parameters
//
// - f: A function that takes A and returns Thunk[B] for side effects
//
// # Returns
//
// - Operator[C, A, A]: A function that executes the Thunk but preserves the original value
//
// # Example
//
// performSideEffect := func(n int) readerioresult.ReaderIOResult[any] {
// return func(ctx context.Context) io.IO[result.Result[any]] {
// return func() result.Result[any] {
// // Perform context-independent IO operation
// log.Printf("Processing value: %d", n)
// return result.Of[any](nil)
// }
// }
// }
//
// eff := effect.Of[MyContext](42)
// tapped := effect.TapThunkK[MyContext](performSideEffect)(eff)
// // Logs "Processing value: 42" but still produces 42
//
// # See Also
//
// - ChainFirstThunkK: The underlying implementation
// - TapIOK: Similar but for IO operations
// - Tap: Similar but for full effects
//
//go:inline
func TapThunkK[C, A, B any](f thunk.Kleisli[A, B]) Operator[C, A, A] {
return ChainFirstThunkK[C](f)
}
// ChainIOK chains an effect with a function that returns an IO action.
// This is useful for integrating IO-based computations (synchronous side effects)
// into effect chains. The IO action is automatically lifted into the Effect context.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: A function that takes A and returns IO[B]
//
// # Returns
//
// - Operator[C, A, B]: A function that chains the IO-returning function with the effect
//
// # Example
//
// performIO := func(n int) io.IO[string] {
// return func() string {
// // Perform synchronous side effect
// return fmt.Sprintf("Value: %d", n)
// }
// }
//
// eff := effect.Of[MyContext](42)
// chained := effect.ChainIOK[MyContext](performIO)(eff)
// // chained produces "Value: 42"
//
//go:inline
func ChainIOK[C, A, B any](f io.Kleisli[A, B]) Operator[C, A, B] {
return readerreaderioresult.ChainIOK[C](f)
}
// ChainFirstIOK chains an effect with a function that returns an IO action,
// but discards the result and returns the original value.
// This is useful for performing side effects (like logging) without changing the value.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The value type (preserved)
// - B: The type produced by the IO action (discarded)
//
// # Parameters
//
// - f: A function that takes A and returns IO[B] for side effects
//
// # Returns
//
// - Operator[C, A, A]: A function that executes the IO action but preserves the original value
//
// # Example
//
// logValue := func(n int) io.IO[any] {
// return func() any {
// fmt.Printf("Processing: %d\n", n)
// return nil
// }
// }
//
// eff := effect.Of[MyContext](42)
// logged := effect.ChainFirstIOK[MyContext](logValue)(eff)
// // Prints "Processing: 42" but still produces 42
//
//go:inline
func ChainFirstIOK[C, A, B any](f io.Kleisli[A, B]) Operator[C, A, A] {
return readerreaderioresult.ChainFirstIOK[C](f)
}
// TapIOK is an alias for ChainFirstIOK.
// It chains an effect with a function that returns an IO action for side effects,
// but preserves the original value. This is useful for logging, debugging, or
// performing actions without changing the result.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The value type (preserved)
// - B: The type produced by the IO action (discarded)
//
// # Parameters
//
// - f: A function that takes A and returns IO[B] for side effects
//
// # Returns
//
// - Operator[C, A, A]: A function that executes the IO action but preserves the original value
//
// # Example
//
// logValue := func(n int) io.IO[any] {
// return func() any {
// fmt.Printf("Value: %d\n", n)
// return nil
// }
// }
//
// eff := effect.Of[MyContext](42)
// tapped := effect.TapIOK[MyContext](logValue)(eff)
// // Prints "Value: 42" but still produces 42
//
//go:inline
func TapIOK[C, A, B any](f io.Kleisli[A, B]) Operator[C, A, A] {
return readerreaderioresult.ChainFirstIOK[C](f)
}
// Ap applies a function wrapped in an Effect to a value wrapped in an Effect.
// This is the applicative apply operation, useful for applying effects in parallel.
//
// # Type Parameters
//
// - B: The output value type
// - C: The context type required by the effects
// - A: The input value type
//
// # Parameters
//
// - fa: The effect containing the value to apply the function to
//
// # Returns
//
// - Operator[C, func(A) B, B]: A function that applies the function effect to the value effect
//
// # Example
//
// fnEff := effect.Of[MyContext](func(x int) int { return x * 2 })
// valEff := effect.Of[MyContext](21)
// result := effect.Ap[int](valEff)(fnEff)
// // result produces 42
func Ap[B, C, A any](fa Effect[C, A]) Operator[C, func(A) B, B] {
return readerreaderioresult.Ap[B](fa)
}
// Suspend delays the evaluation of an effect until it is run.
// This is useful for recursive effects or when you need lazy evaluation.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The type of the success value
//
// # Parameters
//
// - fa: A lazy computation that produces an effect
//
// # Returns
//
// - Effect[C, A]: An effect that evaluates the lazy computation when run
//
// # Example
//
// var recursiveEff func(int) Effect[MyContext, int]
// recursiveEff = func(n int) Effect[MyContext, int] {
// if n <= 0 {
// return effect.Of[MyContext](0)
// }
// return effect.Suspend(func() Effect[MyContext, int] {
// return effect.Map[MyContext](func(x int) int {
// return x + n
// })(recursiveEff(n - 1))
// })
// }
func Suspend[C, A any](fa Lazy[Effect[C, A]]) Effect[C, A] {
return readerreaderioresult.Defer(fa)
}
// Tap executes a side effect for its effect, but returns the original value.
// This is useful for logging, debugging, or performing actions without changing the result.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - A: The value type
// - ANY: The type produced by the side effect (ignored)
//
// # Parameters
//
// - f: A function that performs a side effect based on the value
//
// # Returns
//
// - Operator[C, A, A]: A function that executes the side effect but preserves the original value
//
// # Example
//
// eff := effect.Of[MyContext](42)
// tapped := effect.Tap[MyContext](func(x int) Effect[MyContext, any] {
// fmt.Println("Value:", x)
// return effect.Of[MyContext, any](nil)
// })(eff)
// // Prints "Value: 42" but still produces 42
func Tap[C, A, ANY any](f Kleisli[C, A, ANY]) Operator[C, A, A] {
return readerreaderioresult.Tap(f)
}
// Ternary creates a conditional effect based on a predicate.
// If the predicate returns true, onTrue is executed; otherwise, onFalse is executed.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - pred: A predicate function to test the input value
// - onTrue: The effect to execute if the predicate is true
// - onFalse: The effect to execute if the predicate is false
//
// # Returns
//
// - Kleisli[C, A, B]: A function that conditionally executes one of two effects
//
// # Example
//
// kleisli := effect.Ternary(
// func(x int) bool { return x > 10 },
// func(x int) Effect[MyContext, string] {
// return effect.Of[MyContext]("large")
// },
// func(x int) Effect[MyContext, string] {
// return effect.Of[MyContext]("small")
// },
// )
// result := kleisli(15) // produces "large"
func Ternary[C, A, B any](pred Predicate[A], onTrue, onFalse Kleisli[C, A, B]) Kleisli[C, A, B] {
return function.Ternary(pred, onTrue, onFalse)
}
// ChainResultK chains an effect with a function that returns a Result.
// This is useful for integrating Result-based computations into effect chains.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: A function that takes A and returns Result[B]
//
// # Returns
//
// - Operator[C, A, B]: A function that chains the Result-returning function with the effect
//
// # Example
//
// parseIntResult := result.Eitherize1(strconv.Atoi)
// eff := effect.Of[MyContext]("42")
// chained := effect.ChainResultK[MyContext](parseIntResult)(eff)
// // chained produces 42 as an int
//
//go:inline
func ChainResultK[C, A, B any](f result.Kleisli[A, B]) Operator[C, A, B] {
return readerreaderioresult.ChainResultK[C](f)
}
// ChainReaderK chains an effect with a function that returns a Reader.
// This is useful for integrating Reader-based computations (pure context-dependent functions)
// into effect chains. The Reader is automatically lifted into the Effect context.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: A function that takes A and returns Reader[C, B]
//
// # Returns
//
// - Operator[C, A, B]: A function that chains the Reader-returning function with the effect
//
// # Example
//
// type Config struct { Multiplier int }
//
// getMultiplied := func(n int) reader.Reader[Config, int] {
// return func(cfg Config) int {
// return n * cfg.Multiplier
// }
// }
//
// eff := effect.Of[Config](5)
// chained := effect.ChainReaderK[Config](getMultiplied)(eff)
// // With Config{Multiplier: 3}, produces 15
//
//go:inline
func ChainReaderK[C, A, B any](f reader.Kleisli[C, A, B]) Operator[C, A, B] {
return readerreaderioresult.ChainReaderK(f)
}
// ChainThunkK chains an effect with a function that returns a Thunk.
// This is useful for integrating Thunk-based computations (context-independent IO with error handling)
// into effect chains. The Thunk is automatically lifted into the Effect context.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: A function that takes A and returns Thunk[B] (readerioresult.Kleisli[A, B])
//
// # Returns
//
// - Operator[C, A, B]: A function that chains the Thunk-returning function with the effect
//
// # Example
//
// performIO := func(n int) readerioresult.ReaderIOResult[string] {
// return func(ctx context.Context) io.IO[result.Result[string]] {
// return func() result.Result[string] {
// // Perform IO operation that doesn't need effect context
// return result.Of(fmt.Sprintf("Processed: %d", n))
// }
// }
// }
//
// eff := effect.Of[MyContext](42)
// chained := effect.ChainThunkK[MyContext](performIO)(eff)
// // chained produces "Processed: 42"
//
//go:inline
func ChainThunkK[C, A, B any](f thunk.Kleisli[A, B]) Operator[C, A, B] {
return fromreader.ChainReaderK(
Chain[C, A, B],
FromThunk[C, B],
f,
)
}
// ChainReaderIOK chains an effect with a function that returns a ReaderIO.
// This is useful for integrating ReaderIO-based computations (context-dependent IO operations)
// into effect chains. The ReaderIO is automatically lifted into the Effect context.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input value type
// - B: The output value type
//
// # Parameters
//
// - f: A function that takes A and returns ReaderIO[C, B]
//
// # Returns
//
// - Operator[C, A, B]: A function that chains the ReaderIO-returning function with the effect
//
// # Example
//
// type Config struct { LogPrefix string }
//
// logAndDouble := func(n int) readerio.ReaderIO[Config, int] {
// return func(cfg Config) io.IO[int] {
// return func() int {
// fmt.Printf("%s: %d\n", cfg.LogPrefix, n)
// return n * 2
// }
// }
// }
//
// eff := effect.Of[Config](21)
// chained := effect.ChainReaderIOK[Config](logAndDouble)(eff)
// // Logs "prefix: 21" and produces 42
//
//go:inline
func ChainReaderIOK[C, A, B any](f readerio.Kleisli[C, A, B]) Operator[C, A, B] {
return readerreaderioresult.ChainReaderIOK(f)
}
// Read provides a context to an effect, partially applying it.
// This converts an Effect[C, A] to a Thunk[A] by supplying the required context.
//
// # Type Parameters
//
// - A: The type of the success value
// - C: The context type
//
// # Parameters
//
// - c: The context to provide to the effect
//
// # Returns
//
// - func(Effect[C, A]) Thunk[A]: A function that converts an effect to a thunk
//
// # Example
//
// ctx := MyContext{Value: "test"}
// eff := effect.Of[MyContext](42)
// thunk := effect.Read[int](ctx)(eff)
// // thunk is now a Thunk[int] that can be run without context
//
//go:inline
func Read[A, C any](c C) func(Effect[C, A]) Thunk[A] {
return readerreaderioresult.Read[A](c)
}
// Asks creates an Effect that projects a value from the context using a Reader function.
// This is useful for extracting specific fields or computing derived values from the context.
// It's essentially a lifted version of the Reader pattern into the Effect context.
//
// # Type Parameters
//
// - C: The context type
// - A: The type of the projected value
//
// # Parameters
//
// - r: A Reader function that extracts or computes a value from the context
//
// # Returns
//
// - Effect[C, A]: An effect that succeeds with the projected value
//
// # Example
//
// type Config struct {
// Host string
// Port int
// }
//
// // Extract a specific field
// getHost := effect.Asks[Config](func(cfg Config) string {
// return cfg.Host
// })
//
// // Compute a derived value
// getURL := effect.Asks[Config](func(cfg Config) string {
// return fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
// })
//
// result, err := runEffect(getHost, Config{Host: "localhost", Port: 8080})
// // result == "localhost", err == nil
//
// # See Also
//
// See Also:
//
// - Ask: Returns the entire context as the value
// - Map: Transforms the value after extraction
//
//go:inline
func Asks[C, A any](r Reader[C, A]) Effect[C, A] {
return readerreaderioresult.Asks(r)
}

View File

@@ -0,0 +1,649 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"context"
"errors"
"fmt"
"strconv"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// TestSucceed tests the Succeed function
func TestSucceed_Success(t *testing.T) {
t.Run("creates successful effect with int", func(t *testing.T) {
eff := Succeed[TestConfig](42)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("creates successful effect with string", func(t *testing.T) {
eff := Succeed[TestConfig]("hello")
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of("hello"), outcome)
})
t.Run("creates successful effect with zero value", func(t *testing.T) {
eff := Succeed[TestConfig](0)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(0), outcome)
})
}
// TestFail tests the Fail function
func TestFail_Failure(t *testing.T) {
t.Run("creates failed effect with error", func(t *testing.T) {
testErr := errors.New("test error")
eff := Fail[TestConfig, int](testErr)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
})
t.Run("preserves error message", func(t *testing.T) {
testErr := errors.New("specific error message")
eff := Fail[TestConfig, string](testErr)
outcome := eff(testConfig)(context.Background())()
assert.True(t, result.IsLeft(outcome))
extractedErr := result.MonadFold(outcome,
F.Identity[error],
func(string) error { return nil },
)
assert.Equal(t, testErr, extractedErr)
})
}
// TestOf tests the Of function
func TestOf_Success(t *testing.T) {
t.Run("creates successful effect with value", func(t *testing.T) {
eff := Of[TestConfig](100)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(100), outcome)
})
t.Run("is equivalent to Succeed", func(t *testing.T) {
value := "test"
eff1 := Of[TestConfig](value)
eff2 := Succeed[TestConfig](value)
outcome1 := eff1(testConfig)(context.Background())()
outcome2 := eff2(testConfig)(context.Background())()
assert.Equal(t, outcome1, outcome2)
})
}
// TestMap tests the Map function
func TestMap_Success(t *testing.T) {
t.Run("transforms success value", func(t *testing.T) {
eff := F.Pipe1(
Of[TestConfig](42),
Map[TestConfig](func(x int) int { return x * 2 }),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(84), outcome)
})
t.Run("transforms type", func(t *testing.T) {
eff := F.Pipe1(
Of[TestConfig](42),
Map[TestConfig](func(x int) string { return strconv.Itoa(x) }),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of("42"), outcome)
})
t.Run("chains multiple maps", func(t *testing.T) {
eff := F.Pipe2(
Of[TestConfig](10),
Map[TestConfig](func(x int) int { return x + 5 }),
Map[TestConfig](func(x int) int { return x * 2 }),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(30), outcome)
})
}
func TestMap_Failure(t *testing.T) {
t.Run("propagates error unchanged", func(t *testing.T) {
testErr := errors.New("test error")
eff := F.Pipe1(
Fail[TestConfig, int](testErr),
Map[TestConfig](func(x int) int { return x * 2 }),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
})
}
// TestChain tests the Chain function
func TestChain_Success(t *testing.T) {
t.Run("sequences two effects", func(t *testing.T) {
eff := F.Pipe1(
Of[TestConfig](42),
Chain(func(x int) Effect[TestConfig, string] {
return Of[TestConfig](strconv.Itoa(x))
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of("42"), outcome)
})
t.Run("chains multiple effects", func(t *testing.T) {
eff := F.Pipe2(
Of[TestConfig](10),
Chain(func(x int) Effect[TestConfig, int] {
return Of[TestConfig](x + 5)
}),
Chain(func(x int) Effect[TestConfig, int] {
return Of[TestConfig](x * 2)
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(30), outcome)
})
}
func TestChain_Failure(t *testing.T) {
t.Run("propagates error from first effect", func(t *testing.T) {
testErr := errors.New("first error")
eff := F.Pipe1(
Fail[TestConfig, int](testErr),
Chain(func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("should not execute")
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[string](testErr), outcome)
})
t.Run("propagates error from second effect", func(t *testing.T) {
testErr := errors.New("second error")
eff := F.Pipe1(
Of[TestConfig](42),
Chain(func(x int) Effect[TestConfig, string] {
return Fail[TestConfig, string](testErr)
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[string](testErr), outcome)
})
}
// TestChainIOK tests the ChainIOK function
func TestChainIOK_Success(t *testing.T) {
t.Run("chains with IO action", func(t *testing.T) {
counter := 0
eff := F.Pipe1(
Of[TestConfig](42),
ChainIOK[TestConfig](func(x int) io.IO[string] {
return func() string {
counter++
return fmt.Sprintf("Value: %d", x)
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of("Value: 42"), outcome)
assert.Equal(t, 1, counter)
})
t.Run("chains multiple IO actions", func(t *testing.T) {
log := []string{}
eff := F.Pipe2(
Of[TestConfig](10),
ChainIOK[TestConfig](func(x int) io.IO[int] {
return func() int {
log = append(log, "first")
return x + 5
}
}),
ChainIOK[TestConfig](func(x int) io.IO[int] {
return func() int {
log = append(log, "second")
return x * 2
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(30), outcome)
assert.Equal(t, []string{"first", "second"}, log)
})
}
func TestChainIOK_Failure(t *testing.T) {
t.Run("propagates error from previous effect", func(t *testing.T) {
testErr := errors.New("test error")
executed := false
eff := F.Pipe1(
Fail[TestConfig, int](testErr),
ChainIOK[TestConfig](func(x int) io.IO[string] {
return func() string {
executed = true
return "should not execute"
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[string](testErr), outcome)
assert.False(t, executed)
})
}
// TestChainFirstIOK tests the ChainFirstIOK function
func TestChainFirstIOK_Success(t *testing.T) {
t.Run("executes IO but preserves value", func(t *testing.T) {
log := []string{}
eff := F.Pipe1(
Of[TestConfig](42),
ChainFirstIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
log = append(log, fmt.Sprintf("logged: %d", x))
return nil
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, []string{"logged: 42"}, log)
})
t.Run("chains multiple side effects", func(t *testing.T) {
log := []string{}
eff := F.Pipe2(
Of[TestConfig](10),
ChainFirstIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
log = append(log, "first")
return nil
}
}),
ChainFirstIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
log = append(log, "second")
return nil
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(10), outcome)
assert.Equal(t, []string{"first", "second"}, log)
})
}
func TestChainFirstIOK_Failure(t *testing.T) {
t.Run("propagates error without executing IO", func(t *testing.T) {
testErr := errors.New("test error")
executed := false
eff := F.Pipe1(
Fail[TestConfig, int](testErr),
ChainFirstIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
executed = true
return nil
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
assert.False(t, executed)
})
}
// TestTapIOK tests the TapIOK function
func TestTapIOK_Success(t *testing.T) {
t.Run("executes IO but preserves value", func(t *testing.T) {
log := []string{}
eff := F.Pipe1(
Of[TestConfig](42),
TapIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
log = append(log, fmt.Sprintf("tapped: %d", x))
return nil
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, []string{"tapped: 42"}, log)
})
t.Run("is equivalent to ChainFirstIOK", func(t *testing.T) {
log1 := []string{}
log2 := []string{}
eff1 := F.Pipe1(
Of[TestConfig](10),
TapIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
log1 = append(log1, "tap")
return nil
}
}),
)
eff2 := F.Pipe1(
Of[TestConfig](10),
ChainFirstIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
log2 = append(log2, "tap")
return nil
}
}),
)
outcome1 := eff1(testConfig)(context.Background())()
outcome2 := eff2(testConfig)(context.Background())()
assert.Equal(t, outcome1, outcome2)
assert.Equal(t, log1, log2)
})
}
func TestTapIOK_Failure(t *testing.T) {
t.Run("propagates error without executing IO", func(t *testing.T) {
testErr := errors.New("test error")
executed := false
eff := F.Pipe1(
Fail[TestConfig, int](testErr),
TapIOK[TestConfig](func(x int) io.IO[any] {
return func() any {
executed = true
return nil
}
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
assert.False(t, executed)
})
}
// TestChainResultK tests the ChainResultK function
func TestChainResultK_Success(t *testing.T) {
t.Run("chains with Result-returning function", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := F.Pipe1(
Of[TestConfig]("42"),
ChainResultK[TestConfig](parseIntResult),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("chains multiple Result operations", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := F.Pipe2(
Of[TestConfig]("10"),
ChainResultK[TestConfig](parseIntResult),
ChainResultK[TestConfig](func(x int) result.Result[string] {
return result.Of(fmt.Sprintf("Value: %d", x*2))
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of("Value: 20"), outcome)
})
}
func TestChainResultK_Failure(t *testing.T) {
t.Run("propagates error from previous effect", func(t *testing.T) {
testErr := errors.New("test error")
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := F.Pipe1(
Fail[TestConfig, string](testErr),
ChainResultK[TestConfig](parseIntResult),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
})
t.Run("propagates error from Result function", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := F.Pipe1(
Of[TestConfig]("not a number"),
ChainResultK[TestConfig](parseIntResult),
)
outcome := eff(testConfig)(context.Background())()
assert.True(t, result.IsLeft(outcome))
})
}
// TestAp tests the Ap function
func TestAp_Success(t *testing.T) {
t.Run("applies function effect to value effect", func(t *testing.T) {
fnEff := Of[TestConfig](func(x int) int { return x * 2 })
valEff := Of[TestConfig](21)
eff := Ap[int](valEff)(fnEff)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("applies function with different types", func(t *testing.T) {
fnEff := Of[TestConfig](func(x int) string { return strconv.Itoa(x) })
valEff := Of[TestConfig](42)
eff := Ap[string](valEff)(fnEff)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of("42"), outcome)
})
}
func TestAp_Failure(t *testing.T) {
t.Run("propagates error from function effect", func(t *testing.T) {
testErr := errors.New("function error")
fnEff := Fail[TestConfig, func(int) int](testErr)
valEff := Of[TestConfig](42)
eff := Ap[int](valEff)(fnEff)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
})
t.Run("propagates error from value effect", func(t *testing.T) {
testErr := errors.New("value error")
fnEff := Of[TestConfig](func(x int) int { return x * 2 })
valEff := Fail[TestConfig, int](testErr)
eff := Ap[int](valEff)(fnEff)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
})
}
// TestSuspend tests the Suspend function
func TestSuspend_Success(t *testing.T) {
t.Run("delays evaluation of effect", func(t *testing.T) {
counter := 0
eff := Suspend(func() Effect[TestConfig, int] {
counter++
return Of[TestConfig](42)
})
assert.Equal(t, 0, counter, "should not evaluate immediately")
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, 1, counter, "should evaluate when run")
assert.Equal(t, result.Of(42), outcome)
})
t.Run("enables recursive effects", func(t *testing.T) {
var factorial func(int) Effect[TestConfig, int]
factorial = func(n int) Effect[TestConfig, int] {
if n <= 1 {
return Of[TestConfig](1)
}
return Suspend(func() Effect[TestConfig, int] {
return F.Pipe1(
factorial(n-1),
Map[TestConfig](func(x int) int { return x * n }),
)
})
}
outcome := factorial(5)(testConfig)(context.Background())()
assert.Equal(t, result.Of(120), outcome)
})
}
// TestTap tests the Tap function
func TestTap_Success(t *testing.T) {
t.Run("executes side effect but preserves value", func(t *testing.T) {
log := []string{}
eff := F.Pipe1(
Of[TestConfig](42),
Tap(func(x int) Effect[TestConfig, any] {
log = append(log, fmt.Sprintf("tapped: %d", x))
return Of[TestConfig, any](nil)
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, []string{"tapped: 42"}, log)
})
t.Run("chains multiple taps", func(t *testing.T) {
log := []string{}
eff := F.Pipe2(
Of[TestConfig](10),
Tap(func(x int) Effect[TestConfig, any] {
log = append(log, "first")
return Of[TestConfig, any](nil)
}),
Tap(func(x int) Effect[TestConfig, any] {
log = append(log, "second")
return Of[TestConfig, any](nil)
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Of(10), outcome)
assert.Equal(t, []string{"first", "second"}, log)
})
}
func TestTap_Failure(t *testing.T) {
t.Run("propagates error without executing tap", func(t *testing.T) {
testErr := errors.New("test error")
executed := false
eff := F.Pipe1(
Fail[TestConfig, int](testErr),
Tap(func(x int) Effect[TestConfig, any] {
executed = true
return Of[TestConfig, any](nil)
}),
)
outcome := eff(testConfig)(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
assert.False(t, executed)
})
}
// TestTernary tests the Ternary function
func TestTernary_Success(t *testing.T) {
t.Run("executes onTrue when predicate is true", func(t *testing.T) {
kleisli := Ternary(
func(x int) bool { return x > 10 },
func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("large")
},
func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("small")
},
)
outcome := kleisli(15)(testConfig)(context.Background())()
assert.Equal(t, result.Of("large"), outcome)
})
t.Run("executes onFalse when predicate is false", func(t *testing.T) {
kleisli := Ternary(
func(x int) bool { return x > 10 },
func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("large")
},
func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("small")
},
)
outcome := kleisli(5)(testConfig)(context.Background())()
assert.Equal(t, result.Of("small"), outcome)
})
t.Run("works with boundary value", func(t *testing.T) {
kleisli := Ternary(
func(x int) bool { return x >= 10 },
func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("gte")
},
func(x int) Effect[TestConfig, string] {
return Of[TestConfig]("lt")
},
)
outcome := kleisli(10)(testConfig)(context.Background())()
assert.Equal(t, result.Of("gte"), outcome)
})
}
// TestRead tests the Read function
func TestRead_Success(t *testing.T) {
t.Run("provides context to effect", func(t *testing.T) {
eff := Of[TestConfig](42)
thunk := Read[int](testConfig)(eff)
outcome := thunk(context.Background())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("converts effect to thunk", func(t *testing.T) {
eff := F.Pipe1(
Of[TestConfig](10),
Map[TestConfig](func(x int) int { return x * testConfig.Multiplier }),
)
thunk := Read[int](testConfig)(eff)
outcome := thunk(context.Background())()
assert.Equal(t, result.Of(30), outcome)
})
t.Run("works with different contexts", func(t *testing.T) {
cfg1 := TestConfig{Multiplier: 2, Prefix: "A", DatabaseURL: ""}
cfg2 := TestConfig{Multiplier: 5, Prefix: "B", DatabaseURL: ""}
// Create an effect that uses the context's Multiplier
eff := F.Pipe1(
Of[TestConfig](10),
ChainReaderK(func(x int) reader.Reader[TestConfig, int] {
return func(cfg TestConfig) int {
return x * cfg.Multiplier
}
}),
)
thunk1 := Read[int](cfg1)(eff)
thunk2 := Read[int](cfg2)(eff)
outcome1 := thunk1(context.Background())()
outcome2 := thunk2(context.Background())()
assert.Equal(t, result.Of(20), outcome1)
assert.Equal(t, result.Of(50), outcome2)
})
}
func TestRead_Failure(t *testing.T) {
t.Run("propagates error from effect", func(t *testing.T) {
testErr := errors.New("test error")
eff := Fail[TestConfig, int](testErr)
thunk := Read[int](testConfig)(eff)
outcome := thunk(context.Background())()
assert.Equal(t, result.Left[int](testErr), outcome)
})
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"context"
"errors"
"strconv"
"testing"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
func TestRead(t *testing.T) {
t.Run("provides context to effect", func(t *testing.T) {
ctx := TestContext{Value: "test-context"}
eff := Of[TestContext](42)
thunk := Read[int](ctx)(eff)
ioResult := thunk(context.Background())
res := ioResult()
assert.True(t, result.IsRight(res))
value, err := result.Unwrap(res)
assert.NoError(t, err)
assert.Equal(t, 42, value)
})
t.Run("provides context to failing effect", func(t *testing.T) {
expectedErr := errors.New("read error")
ctx := TestContext{Value: "test"}
eff := Fail[TestContext, string](expectedErr)
thunk := Read[string](ctx)(eff)
ioResult := thunk(context.Background())
res := ioResult()
assert.True(t, result.IsLeft(res))
_, err := result.Unwrap(res)
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("provides context to chained effects", func(t *testing.T) {
ctx := TestContext{Value: "base"}
eff := Chain(func(x int) Effect[TestContext, string] {
return Of[TestContext](strconv.Itoa(x * 2))
})(Of[TestContext](21))
thunk := Read[string](ctx)(eff)
ioResult := thunk(context.Background())
res := ioResult()
assert.True(t, result.IsRight(res))
value, err := result.Unwrap(res)
assert.NoError(t, err)
assert.Equal(t, "42", value)
})
t.Run("works with different context types", func(t *testing.T) {
type CustomContext struct {
ID int
Name string
}
ctx := CustomContext{ID: 100, Name: "custom"}
eff := Of[CustomContext]("result")
thunk := Read[string](ctx)(eff)
ioResult := thunk(context.Background())
res := ioResult()
assert.True(t, result.IsRight(res))
value, err := result.Unwrap(res)
assert.NoError(t, err)
assert.Equal(t, "result", value)
})
t.Run("can be composed with RunSync", func(t *testing.T) {
ctx := TestContext{Value: "test"}
eff := Of[TestContext](100)
thunk := Read[int](ctx)(eff)
readerResult := RunSync(thunk)
value, err := readerResult(context.Background())
assert.NoError(t, err)
assert.Equal(t, 100, value)
})
}
func TestChainResultK(t *testing.T) {
t.Run("chains successful Result function", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := Of[TestContext]("42")
chained := ChainResultK[TestContext](parseIntResult)(eff)
result, err := runEffect(chained, TestContext{Value: "test"})
assert.NoError(t, err)
assert.Equal(t, 42, result)
})
t.Run("chains failing Result function", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := Of[TestContext]("not-a-number")
chained := ChainResultK[TestContext](parseIntResult)(eff)
_, err := runEffect(chained, TestContext{Value: "test"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid syntax")
})
t.Run("propagates error from original effect", func(t *testing.T) {
expectedErr := errors.New("original error")
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := Fail[TestContext, string](expectedErr)
chained := ChainResultK[TestContext](parseIntResult)(eff)
_, err := runEffect(chained, TestContext{Value: "test"})
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("chains multiple Result functions", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
formatResult := func(x int) result.Result[string] {
return result.Of("value: " + strconv.Itoa(x))
}
eff := Of[TestContext]("42")
chained := ChainResultK[TestContext](formatResult)(
ChainResultK[TestContext](parseIntResult)(eff),
)
result, err := runEffect(chained, TestContext{Value: "test"})
assert.NoError(t, err)
assert.Equal(t, "value: 42", result)
})
t.Run("integrates with other effect operations", func(t *testing.T) {
parseIntResult := result.Eitherize1(strconv.Atoi)
eff := Map[TestContext](func(x int) string {
return "final: " + strconv.Itoa(x)
})(ChainResultK[TestContext](parseIntResult)(Of[TestContext]("100")))
result, err := runEffect(eff, TestContext{Value: "test"})
assert.NoError(t, err)
assert.Equal(t, "final: 100", result)
})
t.Run("works with custom Result functions", func(t *testing.T) {
validatePositive := func(x int) result.Result[int] {
if x > 0 {
return result.Of(x)
}
return result.Left[int](errors.New("must be positive"))
}
parseIntResult := result.Eitherize1(strconv.Atoi)
// Test with positive number
eff1 := ChainResultK[TestContext](validatePositive)(
ChainResultK[TestContext](parseIntResult)(Of[TestContext]("42")),
)
result1, err1 := runEffect(eff1, TestContext{Value: "test"})
assert.NoError(t, err1)
assert.Equal(t, 42, result1)
// Test with negative number
eff2 := ChainResultK[TestContext](validatePositive)(
ChainResultK[TestContext](parseIntResult)(Of[TestContext]("-5")),
)
_, err2 := runEffect(eff2, TestContext{Value: "test"})
assert.Error(t, err2)
assert.Contains(t, err2.Error(), "must be positive")
})
t.Run("preserves error context", func(t *testing.T) {
customError := errors.New("custom validation error")
validateFunc := func(s string) result.Result[string] {
if len(s) > 0 {
return result.Of(s)
}
return result.Left[string](customError)
}
eff := ChainResultK[TestContext](validateFunc)(Of[TestContext](""))
_, err := runEffect(eff, TestContext{Value: "test"})
assert.Error(t, err)
assert.Equal(t, customError, err)
})
}

File diff suppressed because it is too large Load Diff

208
v2/effect/eitherize.go Normal file
View File

@@ -0,0 +1,208 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"context"
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
)
// Eitherize converts a function that returns a value and error into an Effect.
//
// This function takes a function that accepts a context C and context.Context,
// returning a value T and an error, and converts it into an Effect[C, T].
// The error is automatically converted into a failure, while successful
// values become successes.
//
// This is particularly useful for integrating standard Go error-handling patterns into
// the effect system. It is especially helpful for adapting interface member functions
// that accept a context. When you have an interface method with signature
// (receiver, context.Context) (T, error), you can use Eitherize to convert it into
// an Effect where the receiver becomes the context C.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - T: The success value type
//
// # Parameters
//
// - f: A function that takes C and context.Context and returns (T, error)
//
// # Returns
//
// - Effect[C, T]: An effect that depends on C, performs IO, and produces T
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// }
//
// // A function using standard Go error handling
// func fetchUser(cfg AppConfig, ctx context.Context) (*User, error) {
// // Implementation that may return an error
// return &User{ID: 1, Name: "Alice"}, nil
// }
//
// // Convert to Effect
// fetchUserEffect := effect.Eitherize(fetchUser)
//
// // Use in functional composition
// pipeline := F.Pipe1(
// fetchUserEffect,
// effect.Map[AppConfig](func(u *User) string { return u.Name }),
// )
//
// // Execute with config
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
// result, err := effect.RunSync(effect.Provide[*User](cfg)(pipeline))(context.Background())
//
// # Adapting Interface Methods
//
// Eitherize is particularly useful for adapting interface member functions:
//
// type UserRepository interface {
// GetUser(ctx context.Context, id int) (*User, error)
// }
//
// type UserRepo struct {
// db *sql.DB
// }
//
// func (r *UserRepo) GetUser(ctx context.Context, id int) (*User, error) {
// // Implementation
// return &User{ID: id}, nil
// }
//
// // Adapt the method by binding the first parameter (receiver)
// repo := &UserRepo{db: db}
// getUserEffect := effect.Eitherize(func(id int, ctx context.Context) (*User, error) {
// return repo.GetUser(ctx, id)
// })
//
// // Now getUserEffect has type: Effect[int, *User]
// // The receiver (repo) is captured in the closure
// // The id becomes the context C
//
// # See Also
//
// - Eitherize1: For functions that take an additional parameter
// - readerreaderioresult.Eitherize: The underlying implementation
//
//go:inline
func Eitherize[C, T any](f func(C, context.Context) (T, error)) Effect[C, T] {
return readerreaderioresult.Eitherize(f)
}
// Eitherize1 converts a function that takes an additional parameter and returns a value
// and error into a Kleisli arrow.
//
// This function takes a function that accepts a context C, context.Context, and
// an additional parameter A, returning a value T and an error, and converts it into a
// Kleisli arrow (A -> Effect[C, T]). The error is automatically converted into a failure,
// while successful values become successes.
//
// This is useful for creating composable operations that depend on context and
// an input value, following standard Go error-handling patterns. It is especially helpful
// for adapting interface member functions that accept a context and additional parameters.
// When you have an interface method with signature (receiver, context.Context, A) (T, error),
// you can use Eitherize1 to convert it into a Kleisli arrow where the receiver becomes
// the context C and A becomes the input parameter.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input parameter type
// - T: The success value type
//
// # Parameters
//
// - f: A function that takes C, context.Context, and A, returning (T, error)
//
// # Returns
//
// - Kleisli[C, A, T]: A function from A to Effect[C, T]
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// }
//
// // A function using standard Go error handling
// func fetchUserByID(cfg AppConfig, ctx context.Context, id int) (*User, error) {
// // Implementation that may return an error
// return &User{ID: id, Name: "Alice"}, nil
// }
//
// // Convert to Kleisli arrow
// fetchUserKleisli := effect.Eitherize1(fetchUserByID)
//
// // Use in functional composition with Chain
// pipeline := F.Pipe1(
// effect.Succeed[AppConfig](123),
// effect.Chain[AppConfig](fetchUserKleisli),
// )
//
// // Execute with config
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
// result, err := effect.RunSync(effect.Provide[*User](cfg)(pipeline))(context.Background())
//
// # Adapting Interface Methods
//
// Eitherize1 is particularly useful for adapting interface member functions with parameters:
//
// type UserRepository interface {
// GetUserByID(ctx context.Context, id int) (*User, error)
// UpdateUser(ctx context.Context, user *User) error
// }
//
// type UserRepo struct {
// db *sql.DB
// }
//
// func (r *UserRepo) GetUserByID(ctx context.Context, id int) (*User, error) {
// // Implementation
// return &User{ID: id}, nil
// }
//
// // Adapt the method - receiver becomes C, id becomes A
// repo := &UserRepo{db: db}
// getUserKleisli := effect.Eitherize1(func(r *UserRepo, ctx context.Context, id int) (*User, error) {
// return r.GetUserByID(ctx, id)
// })
//
// // Now getUserKleisli has type: Kleisli[*UserRepo, int, *User]
// // Which is: func(int) Effect[*UserRepo, *User]
// // Use it in composition:
// pipeline := F.Pipe1(
// effect.Succeed[*UserRepo](123),
// effect.Chain[*UserRepo](getUserKleisli),
// )
// result, err := effect.RunSync(effect.Provide[*User](repo)(pipeline))(context.Background())
//
// # See Also
//
// - Eitherize: For functions without an additional parameter
// - Chain: For composing Kleisli arrows
// - readerreaderioresult.Eitherize1: The underlying implementation
//
//go:inline
func Eitherize1[C, A, T any](f func(C, context.Context, A) (T, error)) Kleisli[C, A, T] {
return readerreaderioresult.Eitherize1(f)
}

507
v2/effect/eitherize_test.go Normal file
View File

@@ -0,0 +1,507 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"context"
"errors"
"fmt"
"strconv"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/stretchr/testify/assert"
)
// TestEitherize_Success tests successful conversion with Eitherize
func TestEitherize_Success(t *testing.T) {
t.Run("converts successful function to Effect", func(t *testing.T) {
// Arrange
successFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
return cfg.Prefix + "-success", nil
}
eff := Eitherize(successFunc)
// Act
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, "LOG-success", result)
})
t.Run("preserves context values", func(t *testing.T) {
// Arrange
type ctxKey string
key := ctxKey("testKey")
expectedValue := "contextValue"
contextFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
value := ctx.Value(key)
if value == nil {
return "", errors.New("context value not found")
}
return value.(string), nil
}
eff := Eitherize(contextFunc)
// Act
ioResult := Provide[string](testConfig)(eff)
readerResult := RunSync(ioResult)
ctx := context.WithValue(context.Background(), key, expectedValue)
result, err := readerResult(ctx)
// Assert
assert.NoError(t, err)
assert.Equal(t, expectedValue, result)
})
t.Run("works with different types", func(t *testing.T) {
// Arrange
intFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return cfg.Multiplier, nil
}
eff := Eitherize(intFunc)
// Act
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, 3, result)
})
}
// TestEitherize_Failure tests error handling with Eitherize
func TestEitherize_Failure(t *testing.T) {
t.Run("converts error to failure", func(t *testing.T) {
// Arrange
expectedErr := errors.New("operation failed")
failFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
return "", expectedErr
}
eff := Eitherize(failFunc)
// Act
_, err := runEffect(eff, testConfig)
// Assert
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("preserves error message", func(t *testing.T) {
// Arrange
expectedErr := fmt.Errorf("validation error: field is required")
failFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return 0, expectedErr
}
eff := Eitherize(failFunc)
// Act
_, err := runEffect(eff, testConfig)
// Assert
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
// TestEitherize_EdgeCases tests edge cases for Eitherize
func TestEitherize_EdgeCases(t *testing.T) {
t.Run("handles nil context", func(t *testing.T) {
// Arrange
nilCtxFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
if ctx == nil {
return "nil-context", nil
}
return "non-nil-context", nil
}
eff := Eitherize(nilCtxFunc)
// Act
ioResult := Provide[string](testConfig)(eff)
readerResult := RunSync(ioResult)
result, err := readerResult(nil)
// Assert
assert.NoError(t, err)
assert.Equal(t, "nil-context", result)
})
t.Run("handles zero value config", func(t *testing.T) {
// Arrange
zeroFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
return cfg.Prefix, nil
}
eff := Eitherize(zeroFunc)
// Act
result, err := runEffect(eff, TestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, "", result)
})
t.Run("handles pointer types", func(t *testing.T) {
// Arrange
type User struct {
Name string
}
ptrFunc := func(cfg TestConfig, ctx context.Context) (*User, error) {
return &User{Name: cfg.Prefix}, nil
}
eff := Eitherize(ptrFunc)
// Act
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "LOG", result.Name)
})
}
// TestEitherize_Integration tests integration with other operations
func TestEitherize_Integration(t *testing.T) {
t.Run("composes with Map", func(t *testing.T) {
// Arrange
baseFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return cfg.Multiplier, nil
}
eff := Eitherize(baseFunc)
// Act
pipeline := F.Pipe1(
eff,
Map[TestConfig](func(n int) string { return strconv.Itoa(n) }),
)
result, err := runEffect(pipeline, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, "3", result)
})
t.Run("composes with Chain", func(t *testing.T) {
// Arrange
firstFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
return cfg.Multiplier, nil
}
secondFunc := func(n int) Effect[TestConfig, string] {
return Succeed[TestConfig](fmt.Sprintf("value: %d", n))
}
// Act
pipeline := F.Pipe1(
Eitherize(firstFunc),
Chain[TestConfig](secondFunc),
)
result, err := runEffect(pipeline, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, "value: 3", result)
})
}
// TestEitherize1_Success tests successful conversion with Eitherize1
func TestEitherize1_Success(t *testing.T) {
t.Run("converts successful function to Kleisli", func(t *testing.T) {
// Arrange
multiplyFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
return n * cfg.Multiplier, nil
}
kleisli := Eitherize1(multiplyFunc)
// Act
eff := kleisli(10)
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, 30, result)
})
t.Run("works with string input", func(t *testing.T) {
// Arrange
concatFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
return cfg.Prefix + "-" + s, nil
}
kleisli := Eitherize1(concatFunc)
// Act
eff := kleisli("input")
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, "LOG-input", result)
})
t.Run("preserves context in Kleisli", func(t *testing.T) {
// Arrange
type ctxKey string
key := ctxKey("factor")
scaleFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
factor := ctx.Value(key)
if factor == nil {
return n * cfg.Multiplier, nil
}
return n * factor.(int), nil
}
kleisli := Eitherize1(scaleFunc)
// Act
eff := kleisli(5)
ioResult := Provide[int](testConfig)(eff)
readerResult := RunSync(ioResult)
ctx := context.WithValue(context.Background(), key, 7)
result, err := readerResult(ctx)
// Assert
assert.NoError(t, err)
assert.Equal(t, 35, result)
})
}
// TestEitherize1_Failure tests error handling with Eitherize1
func TestEitherize1_Failure(t *testing.T) {
t.Run("converts error to failure in Kleisli", func(t *testing.T) {
// Arrange
expectedErr := errors.New("division by zero")
divideFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
if n == 0 {
return 0, expectedErr
}
return 100 / n, nil
}
kleisli := Eitherize1(divideFunc)
// Act
eff := kleisli(0)
_, err := runEffect(eff, testConfig)
// Assert
assert.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("preserves error context", func(t *testing.T) {
// Arrange
validateFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
if len(s) > 10 {
return "", fmt.Errorf("string too long: %d > 10", len(s))
}
return s, nil
}
kleisli := Eitherize1(validateFunc)
// Act
eff := kleisli("this-string-is-too-long")
_, err := runEffect(eff, testConfig)
// Assert
assert.Error(t, err)
assert.Contains(t, err.Error(), "string too long")
})
}
// TestEitherize1_EdgeCases tests edge cases for Eitherize1
func TestEitherize1_EdgeCases(t *testing.T) {
t.Run("handles zero value input", func(t *testing.T) {
// Arrange
zeroFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
return n, nil
}
kleisli := Eitherize1(zeroFunc)
// Act
eff := kleisli(0)
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, 0, result)
})
t.Run("handles pointer input", func(t *testing.T) {
// Arrange
type Input struct {
Value int
}
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
if in == nil {
return 0, errors.New("nil input")
}
return in.Value * cfg.Multiplier, nil
}
kleisli := Eitherize1(ptrFunc)
// Act
eff := kleisli(&Input{Value: 7})
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, 21, result)
})
t.Run("handles nil pointer input", func(t *testing.T) {
// Arrange
type Input struct {
Value int
}
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
if in == nil {
return 0, errors.New("nil input")
}
return in.Value, nil
}
kleisli := Eitherize1(ptrFunc)
// Act
eff := kleisli((*Input)(nil))
_, err := runEffect(eff, testConfig)
// Assert
assert.Error(t, err)
assert.Contains(t, err.Error(), "nil input")
})
}
// TestEitherize1_Integration tests integration with other operations
func TestEitherize1_Integration(t *testing.T) {
t.Run("composes with Chain", func(t *testing.T) {
// Arrange
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
return strconv.Atoi(s)
}
doubleFunc := func(n int) Effect[TestConfig, int] {
return Succeed[TestConfig](n * 2)
}
parseKleisli := Eitherize1(parseFunc)
// Act
pipeline := F.Pipe2(
Succeed[TestConfig]("42"),
Chain[TestConfig](parseKleisli),
Chain[TestConfig](doubleFunc),
)
result, err := runEffect(pipeline, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, 84, result)
})
t.Run("handles error in chain", func(t *testing.T) {
// Arrange
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
return strconv.Atoi(s)
}
parseKleisli := Eitherize1(parseFunc)
// Act
pipeline := F.Pipe1(
Succeed[TestConfig]("not-a-number"),
Chain[TestConfig](parseKleisli),
)
_, err := runEffect(pipeline, testConfig)
// Assert
assert.Error(t, err)
})
t.Run("composes multiple Kleisli arrows", func(t *testing.T) {
// Arrange
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
return strconv.Atoi(s)
}
formatFunc := func(cfg TestConfig, ctx context.Context, n int) (string, error) {
return fmt.Sprintf("%s-%d", cfg.Prefix, n), nil
}
parseKleisli := Eitherize1(parseFunc)
formatKleisli := Eitherize1(formatFunc)
// Act
pipeline := F.Pipe2(
Succeed[TestConfig]("123"),
Chain[TestConfig](parseKleisli),
Chain[TestConfig](formatKleisli),
)
result, err := runEffect(pipeline, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, "LOG-123", result)
})
}
// TestEitherize_TypeSafety tests type safety across different scenarios
func TestEitherize_TypeSafety(t *testing.T) {
t.Run("Eitherize with complex types", func(t *testing.T) {
// Arrange
type ComplexResult struct {
Data map[string]int
Count int
}
complexFunc := func(cfg TestConfig, ctx context.Context) (ComplexResult, error) {
return ComplexResult{
Data: map[string]int{cfg.Prefix: cfg.Multiplier},
Count: cfg.Multiplier,
}, nil
}
eff := Eitherize(complexFunc)
// Act
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, 3, result.Data["LOG"])
assert.Equal(t, 3, result.Count)
})
t.Run("Eitherize1 with different input and output types", func(t *testing.T) {
// Arrange
type Input struct {
ID int
}
type Output struct {
Name string
}
convertFunc := func(cfg TestConfig, ctx context.Context, in Input) (Output, error) {
return Output{Name: fmt.Sprintf("%s-%d", cfg.Prefix, in.ID)}, nil
}
kleisli := Eitherize1(convertFunc)
// Act
eff := kleisli(Input{ID: 99})
result, err := runEffect(eff, testConfig)
// Assert
assert.NoError(t, err)
assert.Equal(t, "LOG-99", result.Name)
})
}

296
v2/effect/filter.go Normal file
View File

@@ -0,0 +1,296 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
"github.com/IBM/fp-go/v2/option"
)
// Filter lifts a filtering operation on a higher-kinded type into an Effect operator.
// This is a generic function that works with any filterable data structure by taking
// a filter function and returning an operator that can be used in effect chains.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - HKTA: The higher-kinded type being filtered (e.g., []A, Seq[A])
// - A: The element type being filtered
//
// # Parameters
//
// - filter: A function that takes a predicate and returns an endomorphism on HKTA
//
// # Returns
//
// - func(Predicate[A]) Operator[C, HKTA, HKTA]: A function that takes a predicate
// and returns an operator that filters effects containing HKTA values
//
// # Example Usage
//
// import A "github.com/IBM/fp-go/v2/array"
//
// // Create a custom filter operator for arrays
// filterOp := Filter[MyContext](A.Filter[int])
// isEven := func(n int) bool { return n%2 == 0 }
//
// pipeline := F.Pipe2(
// Succeed[MyContext]([]int{1, 2, 3, 4, 5}),
// filterOp(isEven),
// Map[MyContext](func(arr []int) int { return len(arr) }),
// )
// // Result: Effect that produces 2 (count of even numbers)
//
// # See Also
//
// - FilterArray: Specialized version for array filtering
// - FilterIter: Specialized version for iterator filtering
// - FilterMap: For filtering and mapping simultaneously
//
//go:inline
func Filter[C, HKTA, A any](
filter func(Predicate[A]) Endomorphism[HKTA],
) func(Predicate[A]) Operator[C, HKTA, HKTA] {
return readerreaderioresult.Filter[C](filter)
}
// FilterArray creates an operator that filters array elements within an Effect based on a predicate.
// Elements that satisfy the predicate are kept, while others are removed.
// This is a specialized version of Filter for arrays.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The element type in the array
//
// # Parameters
//
// - p: A predicate function that tests each element
//
// # Returns
//
// - Operator[C, []A, []A]: An operator that filters array elements in an effect
//
// # Example Usage
//
// isPositive := func(n int) bool { return n > 0 }
// filterPositive := FilterArray[MyContext](isPositive)
//
// pipeline := F.Pipe1(
// Succeed[MyContext]([]int{-2, -1, 0, 1, 2, 3}),
// filterPositive,
// )
// // Result: Effect that produces []int{1, 2, 3}
//
// # See Also
//
// - Filter: Generic version for any filterable type
// - FilterIter: For filtering iterators
// - FilterMapArray: For filtering and mapping arrays simultaneously
//
//go:inline
func FilterArray[C, A any](p Predicate[A]) Operator[C, []A, []A] {
return readerreaderioresult.FilterArray[C](p)
}
// FilterIter creates an operator that filters iterator elements within an Effect based on a predicate.
// Elements that satisfy the predicate are kept in the resulting iterator, while others are removed.
// This is a specialized version of Filter for iterators (Seq).
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The element type in the iterator
//
// # Parameters
//
// - p: A predicate function that tests each element
//
// # Returns
//
// - Operator[C, Seq[A], Seq[A]]: An operator that filters iterator elements in an effect
//
// # Example Usage
//
// isEven := func(n int) bool { return n%2 == 0 }
// filterEven := FilterIter[MyContext](isEven)
//
// pipeline := F.Pipe1(
// Succeed[MyContext](slices.Values([]int{1, 2, 3, 4, 5, 6})),
// filterEven,
// )
// // Result: Effect that produces an iterator over [2, 4, 6]
//
// # See Also
//
// - Filter: Generic version for any filterable type
// - FilterArray: For filtering arrays
// - FilterMapIter: For filtering and mapping iterators simultaneously
//
//go:inline
func FilterIter[C, A any](p Predicate[A]) Operator[C, Seq[A], Seq[A]] {
return readerreaderioresult.FilterIter[C](p)
}
// FilterMap lifts a filter-map operation on a higher-kinded type into an Effect operator.
// This combines filtering and mapping in a single operation: elements are transformed
// using a function that returns Option, and only Some values are kept in the result.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - HKTA: The input higher-kinded type (e.g., []A, Seq[A])
// - HKTB: The output higher-kinded type (e.g., []B, Seq[B])
// - A: The input element type
// - B: The output element type
//
// # Parameters
//
// - filter: A function that takes an option.Kleisli and returns a transformation from HKTA to HKTB
//
// # Returns
//
// - func(option.Kleisli[A, B]) Operator[C, HKTA, HKTB]: A function that takes a Kleisli arrow
// and returns an operator that filter-maps effects
//
// # Example Usage
//
// import A "github.com/IBM/fp-go/v2/array"
// import O "github.com/IBM/fp-go/v2/option"
//
// // Parse and filter positive integers
// parsePositive := func(s string) O.Option[int] {
// var n int
// if _, err := fmt.Sscanf(s, "%d", &n); err == nil && n > 0 {
// return O.Some(n)
// }
// return O.None[int]()
// }
//
// filterMapOp := FilterMap[MyContext](A.FilterMap[string, int])
// pipeline := F.Pipe1(
// Succeed[MyContext]([]string{"1", "-2", "3", "invalid", "5"}),
// filterMapOp(parsePositive),
// )
// // Result: Effect that produces []int{1, 3, 5}
//
// # See Also
//
// - FilterMapArray: Specialized version for arrays
// - FilterMapIter: Specialized version for iterators
// - Filter: For filtering without transformation
//
//go:inline
func FilterMap[C, HKTA, HKTB, A, B any](
filter func(option.Kleisli[A, B]) Reader[HKTA, HKTB],
) func(option.Kleisli[A, B]) Operator[C, HKTA, HKTB] {
return readerreaderioresult.FilterMap[C](filter)
}
// FilterMapArray creates an operator that filters and maps array elements within an Effect.
// Each element is transformed using a function that returns Option[B]. Elements that
// produce Some(b) are kept in the result array, while None values are filtered out.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input element type
// - B: The output element type
//
// # Parameters
//
// - p: A Kleisli arrow from A to Option[B] that transforms and filters elements
//
// # Returns
//
// - Operator[C, []A, []B]: An operator that filter-maps array elements in an effect
//
// # Example Usage
//
// import O "github.com/IBM/fp-go/v2/option"
//
// // Double even numbers, filter out odd numbers
// doubleEven := func(n int) O.Option[int] {
// if n%2 == 0 {
// return O.Some(n * 2)
// }
// return O.None[int]()
// }
//
// pipeline := F.Pipe1(
// Succeed[MyContext]([]int{1, 2, 3, 4, 5}),
// FilterMapArray[MyContext](doubleEven),
// )
// // Result: Effect that produces []int{4, 8}
//
// # See Also
//
// - FilterMap: Generic version for any filterable type
// - FilterMapIter: For filter-mapping iterators
// - FilterArray: For filtering without transformation
//
//go:inline
func FilterMapArray[C, A, B any](p option.Kleisli[A, B]) Operator[C, []A, []B] {
return readerreaderioresult.FilterMapArray[C](p)
}
// FilterMapIter creates an operator that filters and maps iterator elements within an Effect.
// Each element is transformed using a function that returns Option[B]. Elements that
// produce Some(b) are kept in the resulting iterator, while None values are filtered out.
//
// # Type Parameters
//
// - C: The context type required by the effect
// - A: The input element type
// - B: The output element type
//
// # Parameters
//
// - p: A Kleisli arrow from A to Option[B] that transforms and filters elements
//
// # Returns
//
// - Operator[C, Seq[A], Seq[B]]: An operator that filter-maps iterator elements in an effect
//
// # Example Usage
//
// import O "github.com/IBM/fp-go/v2/option"
//
// // Parse strings to integers, keeping only valid ones
// parseInt := func(s string) O.Option[int] {
// var n int
// if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
// return O.Some(n)
// }
// return O.None[int]()
// }
//
// pipeline := F.Pipe1(
// Succeed[MyContext](slices.Values([]string{"1", "2", "invalid", "3"})),
// FilterMapIter[MyContext](parseInt),
// )
// // Result: Effect that produces an iterator over [1, 2, 3]
//
// # See Also
//
// - FilterMap: Generic version for any filterable type
// - FilterMapArray: For filter-mapping arrays
// - FilterIter: For filtering without transformation
//
//go:inline
func FilterMapIter[C, A, B any](p option.Kleisli[A, B]) Operator[C, Seq[A], Seq[B]] {
return readerreaderioresult.FilterMapIter[C](p)
}

653
v2/effect/filter_test.go Normal file
View File

@@ -0,0 +1,653 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"errors"
"fmt"
"slices"
"testing"
A "github.com/IBM/fp-go/v2/array"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
O "github.com/IBM/fp-go/v2/option"
"github.com/stretchr/testify/assert"
)
type FilterTestConfig struct {
MaxValue int
MinValue int
}
// Helper to collect iterator results from an effect
func collectSeqEffect[C, A any](eff Effect[C, Seq[A]], cfg C) []A {
result, err := runEffect(eff, cfg)
if err != nil {
return nil
}
return slices.Collect(result)
}
func TestFilterArray_Success(t *testing.T) {
t.Run("filters array keeping matching elements", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
filterOp := FilterArray[FilterTestConfig](isPositive)
input := Succeed[FilterTestConfig]([]int{1, -2, 3, -4, 5})
// Act
result, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{1, 3, 5}, result)
})
t.Run("returns empty array when no elements match", func(t *testing.T) {
// Arrange
isNegative := N.LessThan(0)
filterOp := FilterArray[FilterTestConfig](isNegative)
input := Succeed[FilterTestConfig]([]int{1, 2, 3})
// Act
result, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{}, result)
})
t.Run("returns all elements when all match", func(t *testing.T) {
// Arrange
alwaysTrue := func(n int) bool { return true }
filterOp := FilterArray[FilterTestConfig](alwaysTrue)
input := Succeed[FilterTestConfig]([]int{1, 2, 3})
// Act
result, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{1, 2, 3}, result)
})
}
func TestFilterIter_Success(t *testing.T) {
t.Run("filters iterator keeping matching elements", func(t *testing.T) {
// Arrange
isEven := func(n int) bool { return n%2 == 0 }
filterOp := FilterIter[FilterTestConfig](isEven)
input := Succeed[FilterTestConfig](slices.Values([]int{1, 2, 3, 4, 5, 6}))
// Act
collected := collectSeqEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.Equal(t, []int{2, 4, 6}, collected)
})
t.Run("returns empty iterator when no elements match", func(t *testing.T) {
// Arrange
isNegative := N.LessThan(0)
filterOp := FilterIter[FilterTestConfig](isNegative)
input := Succeed[FilterTestConfig](slices.Values([]int{1, 2, 3}))
// Act
collected := collectSeqEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.Empty(t, collected)
})
}
func TestFilterArray_WithContext(t *testing.T) {
t.Run("uses context for filtering", func(t *testing.T) {
// Arrange
cfg := FilterTestConfig{MaxValue: 100, MinValue: 0}
inRange := func(n int) bool { return n >= cfg.MinValue && n <= cfg.MaxValue }
filterOp := FilterArray[FilterTestConfig](inRange)
input := Succeed[FilterTestConfig]([]int{-10, 50, 150, 75})
// Act
result, err := runEffect(filterOp(input), cfg)
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{50, 75}, result)
})
}
func TestFilterArray_EdgeCases(t *testing.T) {
t.Run("handles empty array", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
filterOp := FilterArray[FilterTestConfig](isPositive)
input := Succeed[FilterTestConfig]([]int{})
// Act
result, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{}, result)
})
t.Run("preserves error from input", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
filterOp := FilterArray[FilterTestConfig](isPositive)
inputErr := errors.New("input error")
input := Fail[FilterTestConfig, []int](inputErr)
// Act
_, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.Error(t, err)
assert.Equal(t, inputErr, err)
})
}
func TestFilterIter_EdgeCases(t *testing.T) {
t.Run("handles empty iterator", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
filterOp := FilterIter[FilterTestConfig](isPositive)
input := Succeed[FilterTestConfig](slices.Values([]int{}))
// Act
collected := collectSeqEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.Empty(t, collected)
})
t.Run("preserves error from input", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
filterOp := FilterIter[FilterTestConfig](isPositive)
inputErr := errors.New("input error")
input := Fail[FilterTestConfig, Seq[int]](inputErr)
// Act
_, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.Error(t, err)
assert.Equal(t, inputErr, err)
})
}
func TestFilter_GenericFilter(t *testing.T) {
t.Run("works with custom filter function", func(t *testing.T) {
// Arrange
customFilter := func(p Predicate[int]) Endomorphism[[]int] {
return A.Filter(p)
}
filterOp := Filter[FilterTestConfig](customFilter)
isEven := func(n int) bool { return n%2 == 0 }
input := Succeed[FilterTestConfig]([]int{1, 2, 3, 4, 5})
// Act
result, err := runEffect(filterOp(isEven)(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{2, 4}, result)
})
}
func TestFilterMapArray_Success(t *testing.T) {
t.Run("filters and maps array elements", func(t *testing.T) {
// Arrange
parsePositive := func(n int) O.Option[string] {
if n > 0 {
return O.Some(fmt.Sprintf("positive:%d", n))
}
return O.None[string]()
}
filterMapOp := FilterMapArray[FilterTestConfig](parsePositive)
input := Succeed[FilterTestConfig]([]int{-1, 2, -3, 4, 5})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []string{"positive:2", "positive:4", "positive:5"}, result)
})
t.Run("returns empty when no elements match", func(t *testing.T) {
// Arrange
neverMatch := func(n int) O.Option[int] {
return O.None[int]()
}
filterMapOp := FilterMapArray[FilterTestConfig](neverMatch)
input := Succeed[FilterTestConfig]([]int{1, 2, 3})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{}, result)
})
t.Run("maps all elements when all match", func(t *testing.T) {
// Arrange
double := func(n int) O.Option[int] {
return O.Some(n * 2)
}
filterMapOp := FilterMapArray[FilterTestConfig](double)
input := Succeed[FilterTestConfig]([]int{1, 2, 3})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{2, 4, 6}, result)
})
}
func TestFilterMapIter_Success(t *testing.T) {
t.Run("filters and maps iterator elements", func(t *testing.T) {
// Arrange
doubleEven := func(n int) O.Option[int] {
if n%2 == 0 {
return O.Some(n * 2)
}
return O.None[int]()
}
filterMapOp := FilterMapIter[FilterTestConfig](doubleEven)
input := Succeed[FilterTestConfig](slices.Values([]int{1, 2, 3, 4, 5}))
// Act
collected := collectSeqEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.Equal(t, []int{4, 8}, collected)
})
}
func TestFilterMapArray_TypeConversion(t *testing.T) {
t.Run("converts int to string", func(t *testing.T) {
// Arrange
intToString := func(n int) O.Option[string] {
if n > 0 {
return O.Some(fmt.Sprintf("%d", n))
}
return O.None[string]()
}
filterMapOp := FilterMapArray[FilterTestConfig](intToString)
input := Succeed[FilterTestConfig]([]int{-1, 2, -3, 4})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []string{"2", "4"}, result)
})
t.Run("converts string to int", func(t *testing.T) {
// Arrange
parseEven := func(s string) O.Option[int] {
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err == nil && n%2 == 0 {
return O.Some(n)
}
return O.None[int]()
}
filterMapOp := FilterMapArray[FilterTestConfig](parseEven)
input := Succeed[FilterTestConfig]([]string{"1", "2", "3", "4", "invalid"})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{2, 4}, result)
})
}
func TestFilterMapArray_EdgeCases(t *testing.T) {
t.Run("handles empty array", func(t *testing.T) {
// Arrange
double := func(n int) O.Option[int] {
return O.Some(n * 2)
}
filterMapOp := FilterMapArray[FilterTestConfig](double)
input := Succeed[FilterTestConfig]([]int{})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{}, result)
})
t.Run("preserves error from input", func(t *testing.T) {
// Arrange
double := func(n int) O.Option[int] {
return O.Some(n * 2)
}
filterMapOp := FilterMapArray[FilterTestConfig](double)
inputErr := errors.New("input error")
input := Fail[FilterTestConfig, []int](inputErr)
// Act
_, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.Error(t, err)
assert.Equal(t, inputErr, err)
})
}
func TestFilterMapIter_EdgeCases(t *testing.T) {
t.Run("handles empty iterator", func(t *testing.T) {
// Arrange
double := func(n int) O.Option[int] {
return O.Some(n * 2)
}
filterMapOp := FilterMapIter[FilterTestConfig](double)
input := Succeed[FilterTestConfig](slices.Values([]int{}))
// Act
collected := collectSeqEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.Empty(t, collected)
})
}
func TestFilterMap_GenericFilterMap(t *testing.T) {
t.Run("works with custom filterMap function", func(t *testing.T) {
// Arrange
customFilterMap := func(f O.Kleisli[int, string]) Reader[[]int, []string] {
return A.FilterMap(f)
}
filterMapOp := FilterMap[FilterTestConfig](customFilterMap)
intToString := func(n int) O.Option[string] {
if n > 0 {
return O.Some(fmt.Sprintf("%d", n))
}
return O.None[string]()
}
input := Succeed[FilterTestConfig]([]int{-1, 2, -3, 4})
// Act
result, err := runEffect(filterMapOp(intToString)(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []string{"2", "4"}, result)
})
}
func TestFilter_Composition(t *testing.T) {
t.Run("chains multiple filters", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
isEven := func(n int) bool { return n%2 == 0 }
filterPositive := FilterArray[FilterTestConfig](isPositive)
filterEven := FilterArray[FilterTestConfig](isEven)
input := Succeed[FilterTestConfig]([]int{-2, -1, 0, 1, 2, 3, 4, 5, 6})
// Act
result, err := runEffect(filterEven(filterPositive(input)), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{2, 4, 6}, result)
})
t.Run("chains filter and filterMap", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
doubleEven := func(n int) O.Option[int] {
if n%2 == 0 {
return O.Some(n * 2)
}
return O.None[int]()
}
filterOp := FilterArray[FilterTestConfig](isPositive)
filterMapOp := FilterMapArray[FilterTestConfig](doubleEven)
input := Succeed[FilterTestConfig]([]int{-2, 1, 2, 3, 4, 5})
// Act
result, err := runEffect(filterMapOp(filterOp(input)), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{4, 8}, result)
})
}
func TestFilter_WithComplexTypes(t *testing.T) {
type User struct {
Name string
Age int
}
t.Run("filters structs", func(t *testing.T) {
// Arrange
isAdult := func(u User) bool { return u.Age >= 18 }
filterOp := FilterArray[FilterTestConfig](isAdult)
users := []User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 16},
{Name: "Charlie", Age: 30},
}
input := Succeed[FilterTestConfig](users)
// Act
result, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
expected := []User{
{Name: "Alice", Age: 25},
{Name: "Charlie", Age: 30},
}
assert.Equal(t, expected, result)
})
t.Run("filterMaps structs to different type", func(t *testing.T) {
// Arrange
extractAdultName := func(u User) O.Option[string] {
if u.Age >= 18 {
return O.Some(u.Name)
}
return O.None[string]()
}
filterMapOp := FilterMapArray[FilterTestConfig](extractAdultName)
users := []User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 16},
{Name: "Charlie", Age: 30},
}
input := Succeed[FilterTestConfig](users)
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []string{"Alice", "Charlie"}, result)
})
}
func TestFilter_BoundaryConditions(t *testing.T) {
t.Run("filters with boundary predicate", func(t *testing.T) {
// Arrange
inRange := func(n int) bool { return n >= 0 && n <= 100 }
filterOp := FilterArray[FilterTestConfig](inRange)
input := Succeed[FilterTestConfig]([]int{-1, 0, 50, 100, 101})
// Act
result, err := runEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{0, 50, 100}, result)
})
t.Run("filterMap with boundary conditions", func(t *testing.T) {
// Arrange
clampToRange := func(n int) O.Option[int] {
if n >= 0 && n <= 100 {
return O.Some(n)
}
return O.None[int]()
}
filterMapOp := FilterMapArray[FilterTestConfig](clampToRange)
input := Succeed[FilterTestConfig]([]int{-1, 0, 50, 100, 101})
// Act
result, err := runEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.NoError(t, err)
assert.Equal(t, []int{0, 50, 100}, result)
})
}
func TestFilter_WithIterators(t *testing.T) {
t.Run("filters large iterator efficiently", func(t *testing.T) {
// Arrange
isEven := func(n int) bool { return n%2 == 0 }
filterOp := FilterIter[FilterTestConfig](isEven)
// Create iterator for range 0-99
makeSeq := func(yield func(int) bool) {
for i := range 100 {
if !yield(i) {
return
}
}
}
input := Succeed[FilterTestConfig](Seq[int](makeSeq))
// Act
collected := collectSeqEffect(filterOp(input), FilterTestConfig{})
// Assert
assert.Equal(t, 50, len(collected))
assert.Equal(t, 0, collected[0])
assert.Equal(t, 98, collected[49])
})
t.Run("filterMap with iterator", func(t *testing.T) {
// Arrange
squareEven := func(n int) O.Option[int] {
if n%2 == 0 {
return O.Some(n * n)
}
return O.None[int]()
}
filterMapOp := FilterMapIter[FilterTestConfig](squareEven)
input := Succeed[FilterTestConfig](slices.Values([]int{1, 2, 3, 4, 5}))
// Act
collected := collectSeqEffect(filterMapOp(input), FilterTestConfig{})
// Assert
assert.Equal(t, []int{4, 16}, collected)
})
}
func TestFilter_ErrorPropagation(t *testing.T) {
t.Run("filter propagates Left through chain", func(t *testing.T) {
// Arrange
isPositive := N.MoreThan(0)
filterOp := FilterArray[FilterTestConfig](isPositive)
originalErr := errors.New("original error")
// Create an effect that fails
failedEffect := F.Pipe1(
Succeed[FilterTestConfig]([]int{1, 2, 3}),
Chain(func([]int) Effect[FilterTestConfig, []int] {
return Fail[FilterTestConfig, []int](originalErr)
}),
)
// Act
_, err := runEffect(filterOp(failedEffect), FilterTestConfig{})
// Assert
assert.Error(t, err)
assert.Equal(t, originalErr, err)
})
t.Run("filterMap propagates Left through chain", func(t *testing.T) {
// Arrange
double := func(n int) O.Option[int] {
return O.Some(n * 2)
}
filterMapOp := FilterMapArray[FilterTestConfig](double)
originalErr := errors.New("original error")
// Create an effect that fails
failedEffect := F.Pipe1(
Succeed[FilterTestConfig]([]int{1, 2, 3}),
Chain(func([]int) Effect[FilterTestConfig, []int] {
return Fail[FilterTestConfig, []int](originalErr)
}),
)
// Act
_, err := runEffect(filterMapOp(failedEffect), FilterTestConfig{})
// Assert
assert.Error(t, err)
assert.Equal(t, originalErr, err)
})
}
func TestFilter_Integration(t *testing.T) {
t.Run("complex filtering pipeline", func(t *testing.T) {
// Arrange: Filter positive numbers, then double evens, then filter > 5
isPositive := N.MoreThan(0)
doubleEven := func(n int) O.Option[int] {
if n%2 == 0 {
return O.Some(n * 2)
}
return O.None[int]()
}
isGreaterThan5 := N.MoreThan(5)
pipeline := F.Pipe3(
Succeed[FilterTestConfig]([]int{-2, -1, 0, 1, 2, 3, 4, 5, 6}),
FilterArray[FilterTestConfig](isPositive),
FilterMapArray[FilterTestConfig](doubleEven),
FilterArray[FilterTestConfig](isGreaterThan5),
)
// Act
result, err := runEffect(pipeline, FilterTestConfig{})
// Assert
assert.NoError(t, err)
// Positive: [1,2,3,4,5,6] -> DoubleEven: [4,8,12] -> >5: [8,12]
assert.Equal(t, []int{8, 12}, result)
})
}

View File

@@ -1,3 +1,18 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
@@ -5,10 +20,66 @@ import (
"github.com/IBM/fp-go/v2/monoid"
)
// ApplicativeMonoid creates a monoid for effects using applicative semantics.
// This combines effects by running both and combining their results using the provided monoid.
// If either effect fails, the combined effect fails.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - A: The value type that has a monoid instance
//
// # Parameters
//
// - m: The monoid instance for combining values of type A
//
// # Returns
//
// - Monoid[Effect[C, A]]: A monoid for combining effects
//
// # Example
//
// stringMonoid := monoid.MakeMonoid(
// func(a, b string) string { return a + b },
// "",
// )
// effectMonoid := effect.ApplicativeMonoid[MyContext](stringMonoid)
// eff1 := effect.Of[MyContext]("Hello")
// eff2 := effect.Of[MyContext](" World")
// combined := effectMonoid.Concat(eff1, eff2)
// // combined produces "Hello World"
func ApplicativeMonoid[C, A any](m monoid.Monoid[A]) Monoid[Effect[C, A]] {
return readerreaderioresult.ApplicativeMonoid[C](m)
}
// AlternativeMonoid creates a monoid for effects using alternative semantics.
// This tries the first effect, and if it fails, tries the second effect.
// If both succeed, their results are combined using the provided monoid.
//
// # Type Parameters
//
// - C: The context type required by the effects
// - A: The value type that has a monoid instance
//
// # Parameters
//
// - m: The monoid instance for combining values of type A
//
// # Returns
//
// - Monoid[Effect[C, A]]: A monoid for combining effects with fallback behavior
//
// # Example
//
// stringMonoid := monoid.MakeMonoid(
// func(a, b string) string { return a + b },
// "",
// )
// effectMonoid := effect.AlternativeMonoid[MyContext](stringMonoid)
// eff1 := effect.Fail[MyContext, string](errors.New("failed"))
// eff2 := effect.Of[MyContext]("fallback")
// combined := effectMonoid.Concat(eff1, eff2)
// // combined produces "fallback" (first failed, so second is used)
func AlternativeMonoid[C, A any](m monoid.Monoid[A]) Monoid[Effect[C, A]] {
return readerreaderioresult.AlternativeMonoid[C](m)
}

86
v2/effect/profunctor.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
F "github.com/IBM/fp-go/v2/function"
)
// Promap is the profunctor map operation that transforms both the input and output of an Effect.
// It applies f to the input context (contravariantly) and g to the output value (covariantly).
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// This operation allows you to:
// - Modify the context before passing it to the Effect (via f)
// - Transform the success value after the computation completes (via g)
//
// Promap is particularly useful for adapting effects to work with different context types
// while simultaneously transforming their output values.
//
// # Type Parameters
//
// - E: The original context type expected by the Effect
// - A: The original success type produced by the Effect
// - D: The new input context type
// - B: The new output success type
//
// # Parameters
//
// - f: Function to transform the input context from D to E (contravariant)
// - g: Function to transform the output success value from A to B (covariant)
//
// # Returns
//
// - A Kleisli arrow that takes an Effect[E, A] and returns a function from D to B
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// APIKey string
// }
//
// type DBConfig struct {
// URL string
// }
//
// // Effect that uses DBConfig and returns an int
// getUserCount := func(cfg DBConfig) effect.Effect[context.Context, int] {
// return effect.Succeed[context.Context](42)
// }
//
// // Transform AppConfig to DBConfig
// extractDBConfig := func(app AppConfig) DBConfig {
// return DBConfig{URL: app.DatabaseURL}
// }
//
// // Transform int to string
// formatCount := func(count int) string {
// return fmt.Sprintf("Users: %d", count)
// }
//
// // Adapt the effect to work with AppConfig and return string
// adapted := effect.Promap(extractDBConfig, formatCount)(getUserCount)
// result := adapted(AppConfig{DatabaseURL: "localhost:5432", APIKey: "secret"})
//
//go:inline
func Promap[E, A, D, B any](f Reader[D, E], g Reader[A, B]) Kleisli[D, Effect[E, A], B] {
return F.Flow2(
Local[A](f),
Map[D](g),
)
}

View File

@@ -0,0 +1,373 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package effect
import (
"context"
"fmt"
"strconv"
"testing"
R "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// Test types for profunctor tests
type AppConfig struct {
DatabaseURL string
APIKey string
Port int
}
type DBConfig struct {
URL string
}
type ServerConfig struct {
Host string
Port int
}
// TestPromapBasic tests basic Promap functionality
func TestPromapBasic(t *testing.T) {
t.Run("transform both context and output", func(t *testing.T) {
// Effect that uses DBConfig and returns an int
getUserCount := Succeed[DBConfig](42)
// Transform AppConfig to DBConfig
extractDBConfig := func(app AppConfig) DBConfig {
return DBConfig{URL: app.DatabaseURL}
}
// Transform int to string
formatCount := func(count int) string {
return fmt.Sprintf("Users: %d", count)
}
// Adapt the effect to work with AppConfig and return string
adapted := Promap(extractDBConfig, formatCount)(getUserCount)
result := adapted(AppConfig{
DatabaseURL: "localhost:5432",
APIKey: "secret",
Port: 8080,
})(context.Background())()
assert.Equal(t, R.Of("Users: 42"), result)
})
t.Run("identity transformations", func(t *testing.T) {
// Effect that returns a value
getValue := Succeed[DBConfig](100)
// Identity transformations
identity := func(x DBConfig) DBConfig { return x }
identityInt := func(x int) int { return x }
// Apply identity transformations
adapted := Promap(identity, identityInt)(getValue)
result := adapted(DBConfig{URL: "localhost"})(context.Background())()
assert.Equal(t, R.Of(100), result)
})
}
// TestPromapComposition tests that Promap composes correctly
func TestPromapComposition(t *testing.T) {
t.Run("compose multiple transformations", func(t *testing.T) {
// Effect that uses ServerConfig and returns the port
getPort := Map[ServerConfig](func(cfg ServerConfig) int {
return cfg.Port
})(Ask[ServerConfig]())
// First transformation: AppConfig -> ServerConfig
extractServerConfig := func(app AppConfig) ServerConfig {
return ServerConfig{Host: "localhost", Port: app.Port}
}
// Second transformation: int -> string
formatPort := func(port int) string {
return fmt.Sprintf(":%d", port)
}
// Apply transformations
adapted := Promap(extractServerConfig, formatPort)(getPort)
result := adapted(AppConfig{
DatabaseURL: "db.example.com",
APIKey: "key123",
Port: 9000,
})(context.Background())()
assert.Equal(t, R.Of(":9000"), result)
})
}
// TestPromapWithErrors tests Promap with effects that can fail
func TestPromapWithErrors(t *testing.T) {
t.Run("propagates errors correctly", func(t *testing.T) {
// Effect that fails
failingEffect := Fail[DBConfig, int](fmt.Errorf("database connection failed"))
// Transformations
extractDBConfig := func(app AppConfig) DBConfig {
return DBConfig{URL: app.DatabaseURL}
}
formatCount := func(count int) string {
return fmt.Sprintf("Count: %d", count)
}
// Apply transformations
adapted := Promap(extractDBConfig, formatCount)(failingEffect)
result := adapted(AppConfig{DatabaseURL: "localhost"})(context.Background())()
assert.True(t, R.IsLeft(result))
err := R.MonadFold(result,
func(e error) error { return e },
func(string) error { return nil },
)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database connection failed")
})
t.Run("output transformation not applied on error", func(t *testing.T) {
callCount := 0
// Effect that fails
failingEffect := Fail[DBConfig, int](fmt.Errorf("error"))
// Transformation that counts calls
countingTransform := func(x int) string {
callCount++
return strconv.Itoa(x)
}
// Apply transformations
adapted := Promap(
func(app AppConfig) DBConfig { return DBConfig{URL: app.DatabaseURL} },
countingTransform,
)(failingEffect)
result := adapted(AppConfig{DatabaseURL: "localhost"})(context.Background())()
assert.True(t, R.IsLeft(result))
assert.Equal(t, 0, callCount, "output transformation should not be called on error")
})
}
// TestPromapWithComplexTypes tests Promap with more complex type transformations
func TestPromapWithComplexTypes(t *testing.T) {
t.Run("transform struct to different struct", func(t *testing.T) {
type User struct {
ID int
Name string
}
type UserDTO struct {
UserID int
FullName string
}
// Effect that uses User and returns a string
getUserInfo := Map[User](func(user User) string {
return fmt.Sprintf("User %s (ID: %d)", user.Name, user.ID)
})(Ask[User]())
// Transform UserDTO to User
dtoToUser := func(dto UserDTO) User {
return User{ID: dto.UserID, Name: dto.FullName}
}
// Transform string to uppercase
toUpper := func(s string) string {
return fmt.Sprintf("INFO: %s", s)
}
// Apply transformations
adapted := Promap(dtoToUser, toUpper)(getUserInfo)
result := adapted(UserDTO{UserID: 123, FullName: "Alice"})(context.Background())()
assert.Equal(t, R.Of("INFO: User Alice (ID: 123)"), result)
})
}
// TestPromapChaining tests chaining multiple Promap operations
func TestPromapChaining(t *testing.T) {
t.Run("chain multiple Promap operations", func(t *testing.T) {
// Base effect that doubles the input
baseEffect := Map[int](func(x int) int {
return x * 2
})(Ask[int]())
// First Promap: string -> int, int -> string
step1 := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
strconv.Itoa,
)(baseEffect)
// Second Promap: float64 -> string, string -> float64
step2 := Promap(
func(f float64) string {
return fmt.Sprintf("%.0f", f)
},
func(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
},
)(step1)
result := step2(21.0)(context.Background())()
assert.Equal(t, R.Of(42.0), result)
})
}
// TestPromapEdgeCases tests edge cases
func TestPromapEdgeCases(t *testing.T) {
t.Run("zero values", func(t *testing.T) {
effect := Map[int](func(x int) int {
return x
})(Ask[int]())
adapted := Promap(
func(s string) int { return 0 },
func(x int) string { return "" },
)(effect)
result := adapted("anything")(context.Background())()
assert.Equal(t, R.Of(""), result)
})
t.Run("nil context handling", func(t *testing.T) {
effect := Succeed[int]("success")
adapted := Promap(
func(s string) int { return 42 },
func(s string) string { return s + "!" },
)(effect)
// Using background context instead of nil
result := adapted("test")(context.Background())()
assert.Equal(t, R.Of("success!"), result)
})
}
// TestPromapIntegration tests integration with other effect operations
func TestPromapIntegration(t *testing.T) {
t.Run("Promap with Map", func(t *testing.T) {
// Base effect that adds 10
baseEffect := Map[int](func(x int) int {
return x + 10
})(Ask[int]())
// Apply Promap
promapped := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
func(x int) int { return x * 2 },
)(baseEffect)
// Apply Map on top
mapped := Map[string](func(x int) string {
return fmt.Sprintf("Result: %d", x)
})(promapped)
result := mapped("5")(context.Background())()
assert.Equal(t, R.Of("Result: 30"), result)
})
t.Run("Promap with Chain", func(t *testing.T) {
// Base effect
baseEffect := Ask[int]()
// Apply Promap
promapped := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
func(x int) int { return x * 2 },
)(baseEffect)
// Chain with another effect
chained := Chain(func(x int) Effect[string, string] {
return Succeed[string](fmt.Sprintf("Value: %d", x))
})(promapped)
result := chained("10")(context.Background())()
assert.Equal(t, R.Of("Value: 20"), result)
})
}
// BenchmarkPromap benchmarks the Promap operation
func BenchmarkPromap(b *testing.B) {
effect := Map[int](func(x int) int {
return x * 2
})(Ask[int]())
adapted := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
strconv.Itoa,
)(effect)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = adapted("42")(ctx)()
}
}
// BenchmarkPromapChained benchmarks chained Promap operations
func BenchmarkPromapChained(b *testing.B) {
baseEffect := Map[int](func(x int) int {
return x * 2
})(Ask[int]())
step1 := Promap(
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
strconv.Itoa,
)(baseEffect)
step2 := Promap(
func(f float64) string {
return fmt.Sprintf("%.0f", f)
},
func(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
},
)(step1)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = step2(21.0)(ctx)()
}
}

Some files were not shown because too many files have changed in this diff Show More