Skip to content

Commit 45fb482

Browse files
schighclaude
andcommitted
Add conditional and mapping pipeline primitives
PipeIf and PipeUnless enable conditional transformations in pipelines. PipeMap splits a string, transforms each part, and rejoins. Includes Lines and Words splitters for common use cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 42880a3 commit 45fb482

6 files changed

Lines changed: 336 additions & 0 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ format(" 42 ") // "00000042"
3535

3636
**PipeErr** composes fallible transformers. Short-circuits on the first error.
3737

38+
**PipeIf** applies a transformation only when a predicate is true. Otherwise passes through.
39+
40+
```go
41+
isLong := func(s string) bool { return len([]rune(s)) > 100 }
42+
clean := str.Pipe(
43+
str.TrimSpace,
44+
str.PipeIf(isLong, str.Truncate(100, "...")),
45+
)
46+
```
47+
48+
**PipeUnless** is the inverse: applies when the predicate is false.
49+
50+
**PipeMap** splits a string, transforms each part, and rejoins.
51+
52+
```go
53+
trimLines := str.PipeMap(str.Lines, "\n", str.TrimSpace)
54+
trimLines(" hello \n world ") // "hello\nworld"
55+
```
56+
57+
Built-in splitters: `Lines` (split on `\n`) and `Words` (split on whitespace).
58+
3859
## Transform
3960

4061
**ToSnakeCase** / **ToCamelCase** / **ToPascalCase** / **ToKebabCase** / **ToTitleCase** / **ToScreamingSnake**

example_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,43 @@ func ExampleSlugifyASCII_pipe() {
142142
// Output: hello-world
143143
}
144144

145+
func ExamplePipeIf() {
146+
isLong := func(s string) bool { return len([]rune(s)) > 10 }
147+
shorten := str.Pipe(
148+
str.TrimSpace,
149+
str.PipeIf(isLong, str.Truncate(10, "...")),
150+
)
151+
fmt.Println(shorten(" hello "))
152+
fmt.Println(shorten(" this is a long string "))
153+
// Output:
154+
// hello
155+
// this is...
156+
}
157+
158+
func ExamplePipeUnless() {
159+
isEmpty := func(s string) bool { return s == "" }
160+
fn := str.PipeUnless(isEmpty, str.SlugifyASCII)
161+
fmt.Println(fn("Hello World"))
162+
fmt.Println(fn(""))
163+
// Output:
164+
// hello-world
165+
//
166+
}
167+
168+
func ExamplePipeMap() {
169+
trimLines := str.PipeMap(str.Lines, "\n", str.TrimSpace)
170+
fmt.Println(trimLines(" hello \n world "))
171+
// Output:
172+
// hello
173+
// world
174+
}
175+
176+
func ExamplePipeMap_words() {
177+
shoutWords := str.PipeMap(str.Words, " ", str.ToScreamingSnake)
178+
fmt.Println(shoutWords("hello world"))
179+
// Output: HELLO WORLD
180+
}
181+
145182
func ExampleLevenshtein() {
146183
fmt.Println(str.Levenshtein("kitten", "sitting"))
147184
// Output: 3

mappers.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package str
2+
3+
import "strings"
4+
5+
// PipeMap returns a transformer that splits the input using split, applies fn
6+
// to each part, and rejoins with the join string. Nil split or nil fn returns
7+
// the input unchanged.
8+
func PipeMap(split func(string) []string, join string, fn func(string) string) func(string) string {
9+
return func(s string) string {
10+
if split == nil || fn == nil {
11+
return s
12+
}
13+
parts := split(s)
14+
if len(parts) == 0 {
15+
return ""
16+
}
17+
for i, p := range parts {
18+
parts[i] = fn(p)
19+
}
20+
return strings.Join(parts, join)
21+
}
22+
}
23+
24+
// Lines splits a string on newlines. Empty string returns []string{""}.
25+
// Splits on "\n" only; "\r" is preserved if present.
26+
func Lines(s string) []string {
27+
return strings.Split(s, "\n")
28+
}
29+
30+
// Words splits a string on whitespace, matching strings.Fields behavior.
31+
// Empty string returns nil.
32+
func Words(s string) []string {
33+
return strings.Fields(s)
34+
}

mappers_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package str
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestPipeMap(t *testing.T) {
9+
t.Run("trim every line", func(t *testing.T) {
10+
fn := PipeMap(Lines, "\n", TrimSpace)
11+
got := fn(" hello \n world ")
12+
if got != "hello\nworld" {
13+
t.Errorf("got %q, want %q", got, "hello\nworld")
14+
}
15+
})
16+
17+
t.Run("uppercase every word", func(t *testing.T) {
18+
fn := PipeMap(Words, " ", strings.ToUpper)
19+
got := fn("hello world")
20+
if got != "HELLO WORLD" {
21+
t.Errorf("got %q, want %q", got, "HELLO WORLD")
22+
}
23+
})
24+
25+
t.Run("nil split is passthrough", func(t *testing.T) {
26+
fn := PipeMap(nil, "\n", strings.ToUpper)
27+
if got := fn("hello"); got != "hello" {
28+
t.Errorf("got %q, want %q", got, "hello")
29+
}
30+
})
31+
32+
t.Run("nil fn is passthrough", func(t *testing.T) {
33+
fn := PipeMap(Lines, "\n", nil)
34+
if got := fn("hello"); got != "hello" {
35+
t.Errorf("got %q, want %q", got, "hello")
36+
}
37+
})
38+
39+
t.Run("empty string", func(t *testing.T) {
40+
fn := PipeMap(Lines, "\n", TrimSpace)
41+
if got := fn(""); got != "" {
42+
t.Errorf("got %q, want empty", got)
43+
}
44+
})
45+
46+
t.Run("single element", func(t *testing.T) {
47+
fn := PipeMap(Lines, "\n", strings.ToUpper)
48+
if got := fn("hello"); got != "HELLO" {
49+
t.Errorf("got %q, want %q", got, "HELLO")
50+
}
51+
})
52+
53+
t.Run("composes in Pipe", func(t *testing.T) {
54+
fn := Pipe(
55+
TrimSpace,
56+
PipeMap(Lines, "\n", TrimSpace),
57+
)
58+
got := fn(" hello \n world ")
59+
if got != "hello\nworld" {
60+
t.Errorf("got %q, want %q", got, "hello\nworld")
61+
}
62+
})
63+
}
64+
65+
func TestLines(t *testing.T) {
66+
t.Run("normal", func(t *testing.T) {
67+
got := Lines("a\nb\nc")
68+
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
69+
t.Errorf("got %v", got)
70+
}
71+
})
72+
73+
t.Run("empty string", func(t *testing.T) {
74+
got := Lines("")
75+
if len(got) != 1 || got[0] != "" {
76+
t.Errorf("got %v, want [\"\"]", got)
77+
}
78+
})
79+
80+
t.Run("no newlines", func(t *testing.T) {
81+
got := Lines("hello")
82+
if len(got) != 1 || got[0] != "hello" {
83+
t.Errorf("got %v", got)
84+
}
85+
})
86+
87+
t.Run("trailing newline", func(t *testing.T) {
88+
got := Lines("a\nb\n")
89+
if len(got) != 3 || got[2] != "" {
90+
t.Errorf("got %v, want [a, b, \"\"]", got)
91+
}
92+
})
93+
94+
t.Run("cr preserved", func(t *testing.T) {
95+
got := Lines("a\r\nb")
96+
if len(got) != 2 || got[0] != "a\r" {
97+
t.Errorf("got %v, want [\"a\\r\", \"b\"]", got)
98+
}
99+
})
100+
}
101+
102+
func TestWords(t *testing.T) {
103+
t.Run("normal", func(t *testing.T) {
104+
got := Words("hello world")
105+
if len(got) != 2 || got[0] != "hello" || got[1] != "world" {
106+
t.Errorf("got %v", got)
107+
}
108+
})
109+
110+
t.Run("empty string", func(t *testing.T) {
111+
got := Words("")
112+
if len(got) != 0 {
113+
t.Errorf("got %v, want empty", got)
114+
}
115+
})
116+
117+
t.Run("multiple spaces", func(t *testing.T) {
118+
got := Words("hello world")
119+
if len(got) != 2 {
120+
t.Errorf("got %v, want 2 elements", got)
121+
}
122+
})
123+
124+
t.Run("single word", func(t *testing.T) {
125+
got := Words("hello")
126+
if len(got) != 1 || got[0] != "hello" {
127+
t.Errorf("got %v", got)
128+
}
129+
})
130+
}

pipe.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,36 @@ func Pipe(fns ...func(string) string) func(string) string {
1212
}
1313
}
1414

15+
// PipeIf returns a transformer that applies fn only when the predicate returns true.
16+
// If the predicate returns false, the input passes through unchanged.
17+
// Nil predicate is treated as always-false (no-op). Nil fn is a no-op.
18+
func PipeIf(predicate func(string) bool, fn func(string) string) func(string) string {
19+
return func(s string) string {
20+
if predicate == nil || fn == nil {
21+
return s
22+
}
23+
if predicate(s) {
24+
return fn(s)
25+
}
26+
return s
27+
}
28+
}
29+
30+
// PipeUnless returns a transformer that applies fn only when the predicate returns false.
31+
// If the predicate returns true, the input passes through unchanged.
32+
// Nil predicate is treated as always-false, so fn always applies. Nil fn is a no-op.
33+
func PipeUnless(predicate func(string) bool, fn func(string) string) func(string) string {
34+
return func(s string) string {
35+
if fn == nil {
36+
return s
37+
}
38+
if predicate == nil || !predicate(s) {
39+
return fn(s)
40+
}
41+
return s
42+
}
43+
}
44+
1545
// PipeErr composes multiple fallible string transformers into a single function.
1646
// Functions are applied left to right. Short-circuits on the first non-nil error;
1747
// the error is returned unwrapped from the failing function. With zero functions,

pipe_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,90 @@ func TestPipe(t *testing.T) {
3636
})
3737
}
3838

39+
func TestPipeIf(t *testing.T) {
40+
isLong := func(s string) bool { return len(s) > 5 }
41+
42+
t.Run("predicate true applies fn", func(t *testing.T) {
43+
fn := PipeIf(isLong, strings.ToUpper)
44+
if got := fn("hello world"); got != "HELLO WORLD" {
45+
t.Errorf("got %q, want %q", got, "HELLO WORLD")
46+
}
47+
})
48+
49+
t.Run("predicate false passes through", func(t *testing.T) {
50+
fn := PipeIf(isLong, strings.ToUpper)
51+
if got := fn("hi"); got != "hi" {
52+
t.Errorf("got %q, want %q", got, "hi")
53+
}
54+
})
55+
56+
t.Run("nil predicate is no-op", func(t *testing.T) {
57+
fn := PipeIf(nil, strings.ToUpper)
58+
if got := fn("hello"); got != "hello" {
59+
t.Errorf("got %q, want %q", got, "hello")
60+
}
61+
})
62+
63+
t.Run("nil fn is no-op", func(t *testing.T) {
64+
fn := PipeIf(isLong, nil)
65+
if got := fn("hello world"); got != "hello world" {
66+
t.Errorf("got %q, want %q", got, "hello world")
67+
}
68+
})
69+
70+
t.Run("composes in Pipe", func(t *testing.T) {
71+
fn := Pipe(
72+
TrimSpace,
73+
PipeIf(isLong, strings.ToUpper),
74+
)
75+
if got := fn(" hello world "); got != "HELLO WORLD" {
76+
t.Errorf("got %q, want %q", got, "HELLO WORLD")
77+
}
78+
if got := fn(" hi "); got != "hi" {
79+
t.Errorf("got %q, want %q", got, "hi")
80+
}
81+
})
82+
83+
t.Run("empty string", func(t *testing.T) {
84+
fn := PipeIf(isLong, strings.ToUpper)
85+
if got := fn(""); got != "" {
86+
t.Errorf("got %q, want empty", got)
87+
}
88+
})
89+
}
90+
91+
func TestPipeUnless(t *testing.T) {
92+
isShort := func(s string) bool { return len(s) <= 5 }
93+
94+
t.Run("predicate false applies fn", func(t *testing.T) {
95+
fn := PipeUnless(isShort, strings.ToUpper)
96+
if got := fn("hello world"); got != "HELLO WORLD" {
97+
t.Errorf("got %q, want %q", got, "HELLO WORLD")
98+
}
99+
})
100+
101+
t.Run("predicate true passes through", func(t *testing.T) {
102+
fn := PipeUnless(isShort, strings.ToUpper)
103+
if got := fn("hi"); got != "hi" {
104+
t.Errorf("got %q, want %q", got, "hi")
105+
}
106+
})
107+
108+
t.Run("nil predicate always applies fn", func(t *testing.T) {
109+
fn := PipeUnless(nil, strings.ToUpper)
110+
if got := fn("hello"); got != "HELLO" {
111+
t.Errorf("got %q, want %q", got, "HELLO")
112+
}
113+
})
114+
115+
t.Run("nil fn is no-op", func(t *testing.T) {
116+
fn := PipeUnless(isShort, nil)
117+
if got := fn("hello world"); got != "hello world" {
118+
t.Errorf("got %q, want %q", got, "hello world")
119+
}
120+
})
121+
}
122+
39123
func TestPipeErr(t *testing.T) {
40124
good := func(s string) (string, error) { return strings.ToUpper(s), nil }
41125
bad := func(s string) (string, error) { return "", errors.New("fail") }

0 commit comments

Comments
 (0)