Skip to content

Commit 2e64fc1

Browse files
authored
Merge pull request #6367 from thaJeztah/template_slicejoin
templates: make "join" work with non-string slices and map values
2 parents 1f2ba2a + e34a342 commit 2e64fc1

File tree

2 files changed

+131
-1
lines changed

2 files changed

+131
-1
lines changed

templates/templates.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ package templates
66
import (
77
"bytes"
88
"encoding/json"
9+
"fmt"
10+
"reflect"
11+
"sort"
912
"strings"
1013
"text/template"
1114
)
@@ -15,7 +18,7 @@ import (
1518
var basicFunctions = template.FuncMap{
1619
"json": formatJSON,
1720
"split": strings.Split,
18-
"join": strings.Join,
21+
"join": joinElements,
1922
"title": strings.Title, //nolint:nolintlint,staticcheck // strings.Title is deprecated, but we only use it for ASCII, so replacing with golang.org/x/text is out of scope
2023
"lower": strings.ToLower,
2124
"upper": strings.ToUpper,
@@ -97,3 +100,40 @@ func formatJSON(v any) string {
97100
// Remove the trailing new line added by the encoder
98101
return strings.TrimSpace(buf.String())
99102
}
103+
104+
// joinElements joins a slice of items with the given separator. It uses
105+
// [strings.Join] if it's a slice of strings, otherwise uses [fmt.Sprint]
106+
// to join each item to the output.
107+
func joinElements(elems any, sep string) (string, error) {
108+
if elems == nil {
109+
return "", nil
110+
}
111+
112+
if ss, ok := elems.([]string); ok {
113+
return strings.Join(ss, sep), nil
114+
}
115+
116+
switch rv := reflect.ValueOf(elems); rv.Kind() { //nolint:exhaustive // ignore: too many options to make exhaustive
117+
case reflect.Array, reflect.Slice:
118+
var b strings.Builder
119+
for i := range rv.Len() {
120+
if i > 0 {
121+
b.WriteString(sep)
122+
}
123+
_, _ = fmt.Fprint(&b, rv.Index(i).Interface())
124+
}
125+
return b.String(), nil
126+
127+
case reflect.Map:
128+
var out []string
129+
for _, k := range rv.MapKeys() {
130+
out = append(out, fmt.Sprint(rv.MapIndex(k).Interface()))
131+
}
132+
// Not ideal, but trying to keep a consistent order
133+
sort.Strings(out)
134+
return strings.Join(out, sep), nil
135+
136+
default:
137+
return "", fmt.Errorf("expected slice, got %T", elems)
138+
}
139+
}

templates/templates_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package templates
33
import (
44
"bytes"
55
"testing"
6+
"text/template"
67

78
"gotest.tools/v3/assert"
89
is "gotest.tools/v3/assert/cmp"
@@ -139,3 +140,92 @@ func TestHeaderFunctions(t *testing.T) {
139140
})
140141
}
141142
}
143+
144+
type stringerString string
145+
146+
func (s stringerString) String() string {
147+
return "stringer" + string(s)
148+
}
149+
150+
type stringerAndError string
151+
152+
func (s stringerAndError) String() string {
153+
return "stringer" + string(s)
154+
}
155+
156+
func (s stringerAndError) Error() string {
157+
return "error" + string(s)
158+
}
159+
160+
func TestJoinElements(t *testing.T) {
161+
tests := []struct {
162+
doc string
163+
data any
164+
expOut string
165+
expErr string
166+
}{
167+
{
168+
doc: "nil",
169+
data: nil,
170+
expOut: `output: ""`,
171+
},
172+
{
173+
doc: "non-slice",
174+
data: "hello",
175+
expOut: `output: "`,
176+
expErr: `error calling join: expected slice, got string`,
177+
},
178+
{
179+
doc: "structs",
180+
data: []struct{ A, B string }{{"1", "2"}, {"3", "4"}},
181+
expOut: `output: "{1 2}, {3 4}"`,
182+
},
183+
{
184+
doc: "map with strings",
185+
data: map[string]string{"A": "1", "B": "2", "C": "3"},
186+
expOut: `output: "1, 2, 3"`,
187+
},
188+
{
189+
doc: "map with stringers",
190+
data: map[string]stringerString{"A": "1", "B": "2", "C": "3"},
191+
expOut: `output: "stringer1, stringer2, stringer3"`,
192+
},
193+
{
194+
doc: "map with errors",
195+
data: []stringerAndError{"1", "2", "3"},
196+
expOut: `output: "error1, error2, error3"`,
197+
},
198+
{
199+
doc: "stringers",
200+
data: []stringerString{"1", "2", "3"},
201+
expOut: `output: "stringer1, stringer2, stringer3"`,
202+
},
203+
{
204+
doc: "stringer with errors",
205+
data: []stringerAndError{"1", "2", "3"},
206+
expOut: `output: "error1, error2, error3"`,
207+
},
208+
{
209+
doc: "slice of bools",
210+
data: []bool{true, false, true},
211+
expOut: `output: "true, false, true"`,
212+
},
213+
}
214+
215+
const formatStr = `output: "{{- join . ", " -}}"`
216+
tmpl, err := New("my-template").Funcs(template.FuncMap{"join": joinElements}).Parse(formatStr)
217+
assert.NilError(t, err)
218+
219+
for _, tc := range tests {
220+
t.Run(tc.doc, func(t *testing.T) {
221+
var b bytes.Buffer
222+
err := tmpl.Execute(&b, tc.data)
223+
if tc.expErr != "" {
224+
assert.ErrorContains(t, err, tc.expErr)
225+
} else {
226+
assert.NilError(t, err)
227+
}
228+
assert.Equal(t, b.String(), tc.expOut)
229+
})
230+
}
231+
}

0 commit comments

Comments
 (0)