Skip to content

Commit 5df8e9f

Browse files
committed
tpl/strings: Add strings.ReplacePairs function
Closes #14954
1 parent c9b88e4 commit 5df8e9f

File tree

3 files changed

+164
-3
lines changed

3 files changed

+164
-3
lines changed

tpl/strings/init.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ func init() {
145145
},
146146
)
147147

148+
ns.AddMethodMapping(ctx.ReplacePairs,
149+
nil,
150+
[][2]string{
151+
{
152+
`{{ "aab" | strings.ReplacePairs "a" "b" "b" "c" }}`,
153+
`bbc`,
154+
},
155+
{
156+
`{{ "aab" | strings.ReplacePairs (slice "a" "b" "b" "c") }}`,
157+
`bbc`,
158+
},
159+
},
160+
)
161+
148162
ns.AddMethodMapping(ctx.ReplaceRE,
149163
[]string{"replaceRE"},
150164
[][2]string{

tpl/strings/strings.go

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323
"unicode"
2424
"unicode/utf8"
2525

26+
"github.com/gohugoio/hugo/common/hmaps"
27+
"github.com/gohugoio/hugo/common/hreflect"
2628
"github.com/gohugoio/hugo/common/text"
2729
"github.com/gohugoio/hugo/deps"
2830
"github.com/gohugoio/hugo/helpers"
@@ -34,14 +36,18 @@ import (
3436

3537
// New returns a new instance of the strings-namespaced template functions.
3638
func New(d *deps.Deps) *Namespace {
37-
return &Namespace{deps: d}
39+
return &Namespace{
40+
deps: d,
41+
replacerCache: hmaps.NewCacheWithOptions[string, *strings.Replacer](hmaps.CacheOptions{Size: 100}),
42+
}
3843
}
3944

4045
// Namespace provides template functions for the "strings" namespace.
4146
// Most functions mimic the Go stdlib, but the order of the parameters may be
4247
// different to ease their use in the Go template system.
4348
type Namespace struct {
44-
deps *deps.Deps
49+
deps *deps.Deps
50+
replacerCache *hmaps.Cache[string, *strings.Replacer]
4551
}
4652

4753
// CountRunes returns the number of runes in s, excluding whitespace.
@@ -251,6 +257,61 @@ func (ns *Namespace) Replace(s, old, new any, limit ...any) (string, error) {
251257
return strings.Replace(ss, so, sn, lim), nil
252258
}
253259

260+
// ReplacePairs returns a copy of a string with multiple replacements performed
261+
// in a single pass. The last argument is the source string. Preceding arguments
262+
// are old/new string pairs, either as a slice or as individual arguments.
263+
func (ns *Namespace) ReplacePairs(args ...any) (string, error) {
264+
if len(args) < 2 {
265+
return "", fmt.Errorf("requires at least 2 arguments")
266+
}
267+
268+
ss, err := cast.ToStringE(args[len(args)-1])
269+
if err != nil {
270+
return "", err
271+
}
272+
273+
var p []string
274+
if len(args) == 2 {
275+
// slice form: ReplacePairs (slice "a" "b") "s"
276+
if !hreflect.IsSlice(args[0]) {
277+
return "", fmt.Errorf("with 2 arguments, the first must be a slice of replacement pairs, got %T", args[0])
278+
}
279+
p, err = cast.ToStringSliceE(args[0])
280+
if err != nil {
281+
return "", err
282+
}
283+
}
284+
if p == nil {
285+
// inline form: ReplacePairs "a" "b" "s"
286+
p = make([]string, len(args)-1)
287+
for i, v := range args[:len(args)-1] {
288+
s, err := cast.ToStringE(v)
289+
if err != nil {
290+
return "", err
291+
}
292+
p[i] = s
293+
}
294+
}
295+
296+
if len(p) == 0 || ss == "" {
297+
return ss, nil
298+
}
299+
300+
if len(p)%2 != 0 {
301+
return "", fmt.Errorf("uneven number of replacement pairs")
302+
}
303+
304+
key := strings.Join(p, "\x00")
305+
replacer, err := ns.replacerCache.GetOrCreate(key, func() (*strings.Replacer, error) {
306+
return strings.NewReplacer(p...), nil
307+
})
308+
if err != nil {
309+
return "", err
310+
}
311+
312+
return replacer.Replace(ss), nil
313+
}
314+
254315
// SliceString slices a string by specifying a half-open range with
255316
// two indices, start and end. 1 and 4 creates a slice including elements 1 through 3.
256317
// The end index can be omitted, it defaults to the string's length.

tpl/strings/strings_test.go

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package strings
1515

1616
import (
1717
"html/template"
18+
stds "strings"
1819
"testing"
1920

2021
qt "github.com/frankban/quicktest"
@@ -366,7 +367,7 @@ func TestReplace(t *testing.T) {
366367
}
367368

368369
if b, ok := test.expect.(bool); ok && !b {
369-
c.Assert(err, qt.Not(qt.IsNil))
370+
c.Assert(err, qt.IsNotNil)
370371
continue
371372
}
372373

@@ -375,6 +376,91 @@ func TestReplace(t *testing.T) {
375376
}
376377
}
377378

379+
func TestReplacePairs(t *testing.T) {
380+
t.Parallel()
381+
c := qt.New(t)
382+
383+
for _, test := range []struct {
384+
args []any
385+
expect string
386+
}{
387+
// slice form
388+
{[]any{[]string{"a", "b"}, "aab"}, "bbb"},
389+
{[]any{[]string{"a", "b", "b", "c"}, "aab"}, "bbc"},
390+
{[]any{[]string{"app", "pear", "apple", "orange"}, "apple"}, "pearle"},
391+
{[]any{[]string{}, "aab"}, "aab"},
392+
{[]any{[]string{"remove-me", ""}, "text remove-me"}, "text "},
393+
{[]any{[]string{"", "X"}, "ab"}, "XaXbX"},
394+
{[]any{[]string{"a", "b"}, template.HTML("aab")}, "bbb"}, // template.HTML source
395+
{[]any{[]string{"a", "b"}, 42}, "42"}, // int source (cast: 42→"42")
396+
{[]any{[]any{"a", "b"}, "s"}, "s"}, // []any with all strings
397+
{[]any{[]any{1, "one"}, "1abc"}, "oneabc"}, // []any with int pair (cast: 1→"1")
398+
// inline form
399+
{[]any{"a", "b", "aab"}, "bbb"},
400+
{[]any{"a", "b", "b", "c", "aab"}, "bbc"},
401+
{[]any{"app", "pear", "apple", "orange", "apple"}, "pearle"},
402+
{[]any{"a", "b", ""}, ""}, // empty source
403+
{[]any{template.HTML("a"), "b", "aab"}, "bbb"}, // template.HTML pair
404+
{[]any{1, "one", "1abc"}, "oneabc"}, // int pair (cast: 1→"1")
405+
} {
406+
result, err := ns.ReplacePairs(test.args...)
407+
c.Assert(err, qt.IsNil)
408+
c.Assert(result, qt.Equals, test.expect)
409+
}
410+
411+
for _, test := range []struct {
412+
args []any
413+
errMatch string
414+
}{
415+
{[]any{}, "requires at least 2"}, // 0 args
416+
{[]any{"s"}, "requires at least 2"}, // 1 arg
417+
{[]any{42, "s"}, "first must be a slice"}, // 2 args: non-slice first arg
418+
{[]any{"a", "s"}, "first must be a slice"}, // 2 args: string first arg (not a slice)
419+
{[]any{[]string{"a"}, "s"}, "uneven number"}, // slice: odd pairs
420+
{[]any{"a", "b", "c", "s"}, "uneven number"}, // inline: 3 pairs
421+
{[]any{[]any{tstNoStringer{}, "b"}, "s"}, "unable to cast"}, // non-castable slice element
422+
{[]any{tstNoStringer{}, "b", "s"}, "unable to cast"}, // non-castable inline pair value
423+
{[]any{[]string{"a", "b"}, tstNoStringer{}}, "unable to cast"}, // non-castable source
424+
} {
425+
_, err := ns.ReplacePairs(test.args...)
426+
c.Assert(err, qt.ErrorMatches, ".*"+test.errMatch+".*")
427+
}
428+
}
429+
430+
func BenchmarkReplacePairs(b *testing.B) {
431+
twoPairs := []string{"a", "A", "b", "B"}
432+
threePairs := []string{"a", "A", "b", "B", "c", "C"}
433+
s := "aabbcc"
434+
435+
b.Run("TwoPairs/cached", func(b *testing.B) {
436+
b.ReportAllocs()
437+
for b.Loop() {
438+
ns.ReplacePairs(twoPairs, s)
439+
}
440+
})
441+
442+
b.Run("TwoPairs/uncached", func(b *testing.B) {
443+
b.ReportAllocs()
444+
for b.Loop() {
445+
stds.NewReplacer(twoPairs...).Replace(s)
446+
}
447+
})
448+
449+
b.Run("ThreePairs/cached", func(b *testing.B) {
450+
b.ReportAllocs()
451+
for b.Loop() {
452+
ns.ReplacePairs(threePairs, s)
453+
}
454+
})
455+
456+
b.Run("ThreePairs/uncached", func(b *testing.B) {
457+
b.ReportAllocs()
458+
for b.Loop() {
459+
stds.NewReplacer(threePairs...).Replace(s)
460+
}
461+
})
462+
}
463+
378464
func TestSliceString(t *testing.T) {
379465
t.Parallel()
380466
c := qt.New(t)

0 commit comments

Comments
 (0)