Skip to content

Commit 215faa1

Browse files
committed
templates: make "join" work with non-string slices and map values
Add a custom join function that allows for non-string slices to be joined, following the same rules as "fmt.Sprint", it will use the fmt.Stringer interface if implemented, or "error" if the type has an "Error()". For maps, it joins the map-values, for example: docker image inspect --format '{{join .Config.Labels ", "}}' ubuntu 24.04, ubuntu Signed-off-by: Sebastiaan van Stijn <[email protected]>
1 parent 306b744 commit 215faa1

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
)
@@ -26,7 +29,7 @@ var basicFunctions = template.FuncMap{
2629
return strings.TrimSpace(buf.String())
2730
},
2831
"split": strings.Split,
29-
"join": strings.Join,
32+
"join": joinElements,
3033
"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
3134
"lower": strings.ToLower,
3235
"upper": strings.ToUpper,
@@ -103,3 +106,40 @@ func truncateWithLength(source string, length int) string {
103106
}
104107
return source[:length]
105108
}
109+
110+
// joinElements joins a slice of items with the given separator. It uses
111+
// [strings.Join] if it's a slice of strings, otherwise uses [fmt.Sprint]
112+
// to join each item to the output.
113+
func joinElements(elems any, sep string) (string, error) {
114+
if elems == nil {
115+
return "", nil
116+
}
117+
118+
if ss, ok := elems.([]string); ok {
119+
return strings.Join(ss, sep), nil
120+
}
121+
122+
switch rv := reflect.ValueOf(elems); rv.Kind() { //nolint:exhaustive // ignore: too many options to make exhaustive
123+
case reflect.Array, reflect.Slice:
124+
var b strings.Builder
125+
for i := range rv.Len() {
126+
if i > 0 {
127+
b.WriteString(sep)
128+
}
129+
_, _ = fmt.Fprint(&b, rv.Index(i).Interface())
130+
}
131+
return b.String(), nil
132+
133+
case reflect.Map:
134+
var out []string
135+
for _, k := range rv.MapKeys() {
136+
out = append(out, fmt.Sprint(rv.MapIndex(k).Interface()))
137+
}
138+
// Not ideal, but trying to keep a consistent order
139+
sort.Strings(out)
140+
return strings.Join(out, sep), nil
141+
142+
default:
143+
return "", fmt.Errorf("expected slice, got %T", elems)
144+
}
145+
}

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)