Skip to content

Commit e34a342

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 <github@gone.nl>
1 parent a86356d commit e34a342

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)