Skip to content

Commit 654ff48

Browse files
committed
Updated code after PR review
1 parent 6566c7a commit 654ff48

File tree

4 files changed

+178
-11
lines changed

4 files changed

+178
-11
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ toolchain go1.24.6
77
require (
88
github.com/forPelevin/gomoji v1.3.1
99
golang.org/x/net v0.43.0
10+
golang.org/x/sync v0.16.0
1011
golang.org/x/text v0.28.0
1112
)
1213

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
44
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
55
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
66
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
7+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
8+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
79
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
810
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=

slugger/slugger.go

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ type Slugger struct {
2020
Substitutions map[string]string // A map of string replacements to apply before generating the slug
2121
WithEmoji bool // If true, emojis will be included in a slug-friendly format
2222

23-
init sync.Once // Controls initialization of the replacer
23+
mu sync.RWMutex // Guards substitutions and replacer; safe for concurrent Slug and updates
2424
replacer *strings.Replacer // Replacer used to handle substitutions in the input string
2525
}
2626

2727
// New creates and returns a new Slugger instance with optional substitutions and emoji handling.
2828
func New(substitutions map[string]string, withEmoji bool) *Slugger {
2929
return &Slugger{
30-
Substitutions: substitutions,
30+
Substitutions: copyMap(substitutions),
3131
WithEmoji: withEmoji,
3232
Separator: defaultSeparator,
3333
}
@@ -50,9 +50,17 @@ func (sl *Slugger) Slug(s, separator string) string {
5050

5151
s = strings.ToLower(s)
5252

53-
sl.init.Do(sl.initReplacer)
54-
if sl.replacer != nil {
55-
s = sl.replacer.Replace(s)
53+
sl.mu.RLock()
54+
r := sl.replacer
55+
sl.mu.RUnlock()
56+
if r == nil {
57+
sl.initReplacer()
58+
sl.mu.RLock()
59+
r = sl.replacer
60+
sl.mu.RUnlock()
61+
}
62+
if r != nil {
63+
s = r.Replace(s)
5664
}
5765

5866
words := normalizeWordsToSafeASCII(s)
@@ -65,42 +73,67 @@ func (sl *Slugger) Slug(s, separator string) string {
6573

6674
// AddSubstitution adds a new substitution to the Slugger and resets the replacer cache.
6775
func (sl *Slugger) AddSubstitution(key, value string) {
76+
sl.mu.Lock()
77+
defer sl.mu.Unlock()
78+
6879
if sl.Substitutions == nil {
6980
sl.Substitutions = make(map[string]string)
7081
}
7182

7283
sl.Substitutions[key] = value
73-
sl.init = sync.Once{}
84+
sl.replacer = nil
7485
}
7586

7687
// RemoveSubstitution deletes a substitution by key and resets the replacer cache.
7788
func (sl *Slugger) RemoveSubstitution(key string) {
89+
sl.mu.Lock()
90+
defer sl.mu.Unlock()
91+
7892
if len(sl.Substitutions) == 0 {
7993
return
8094
}
8195

8296
if _, exists := sl.Substitutions[key]; exists {
8397
delete(sl.Substitutions, key)
84-
sl.init = sync.Once{}
98+
sl.replacer = nil
8599
}
86100
}
87101

88102
// ReplaceSubstitution updates the value of an existing substitution and resets the replacer cache.
89103
func (sl *Slugger) ReplaceSubstitution(key, newValue string) {
104+
sl.mu.Lock()
105+
defer sl.mu.Unlock()
106+
90107
if len(sl.Substitutions) == 0 {
91108
return
92109
}
93110

94111
if _, exists := sl.Substitutions[key]; exists {
95112
sl.Substitutions[key] = newValue
96-
sl.init = sync.Once{}
113+
sl.replacer = nil
97114
}
98115
}
99116

100117
// SetSubstitutions replaces all current substitutions with the provided map and resets the replacer cache.
101118
func (sl *Slugger) SetSubstitutions(substitutions map[string]string) {
102-
sl.Substitutions = substitutions
103-
sl.init = sync.Once{} // Reset the initialization to rebuild the replacer
119+
sl.mu.Lock()
120+
defer sl.mu.Unlock()
121+
122+
sl.Substitutions = copyMap(substitutions)
123+
sl.replacer = nil
124+
}
125+
126+
func copyMap(m map[string]string) map[string]string {
127+
if m == nil {
128+
return make(map[string]string)
129+
}
130+
131+
cp := make(map[string]string, len(m))
132+
for k, v := range m {
133+
cp[k] = v
134+
}
135+
136+
return cp
104137
}
105138

106139
// ligatureReplacer is used to replace common ligatures with their ASCII equivalents.
@@ -140,6 +173,9 @@ func normalizeWordsToSafeASCII(s string) []string {
140173
// initReplacer builds and caches a strings.Replacer from the current substitutions.
141174
// Substitution keys are sorted by length (descending) to ensure longer matches are replaced first.
142175
func (sl *Slugger) initReplacer() {
176+
sl.mu.Lock()
177+
defer sl.mu.Unlock()
178+
143179
// Reset the replacer to nil so that it can be rebuilt with the latest substitutions
144180
sl.replacer = nil
145181

@@ -165,7 +201,7 @@ func (sl *Slugger) initReplacer() {
165201
}
166202

167203
slices.SortFunc(subsPairs, func(a, b subsKV) int {
168-
if la, lb := len(a.k), len(b.k); la != lb {
204+
if la, lb := len([]rune(a.k)), len([]rune(b.k)); la != lb {
169205
return cmp.Compare(lb, la) // sort by key length DESC
170206
}
171207

slugger/slugger_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
package slugger
22

33
import (
4+
"context"
5+
"fmt"
46
"testing"
7+
"time"
8+
"unicode"
9+
10+
"github.com/kashifkhan0771/utils/rand"
11+
"golang.org/x/sync/errgroup"
512
)
613

714
func TestSlugger_Slug(t *testing.T) {
@@ -83,6 +90,14 @@ func TestSlugger_Slug(t *testing.T) {
8390
removeSubstitutionsChange: true,
8491
expected: "hello-and-world-helloworld",
8592
},
93+
{
94+
name: "ReplaceSubstitution updates value only",
95+
input: "Price is 10 %",
96+
separator: "-",
97+
substitutions: map[string]string{"%": "percent"},
98+
substitutionsChange: map[string]string{"%": "pct"},
99+
expected: "price-is-10-pct",
100+
},
86101
{
87102
name: "Clear all substitutions",
88103
input: "Hello & World #HelloWorld",
@@ -204,6 +219,119 @@ func TestSlugger_Slug_EdgeCases(t *testing.T) {
204219
}
205220
}
206221

222+
func TestSluggerConcurrentSubstitutions(t *testing.T) {
223+
t.Parallel()
224+
225+
pick := func(slice []string) string {
226+
if len(slice) == 0 {
227+
return ""
228+
}
229+
p, err := rand.Pick(slice)
230+
if err != nil {
231+
return ""
232+
}
233+
return p
234+
}
235+
236+
intN := func(max int) int {
237+
n, err := rand.NumberInRange(0, int64(max-1))
238+
if err != nil {
239+
return 0
240+
}
241+
return int(n)
242+
}
243+
244+
sl := New(map[string]string{
245+
"hello": "hi",
246+
"😀": "smile",
247+
"æ": "ae",
248+
"foo": "bar",
249+
}, true)
250+
251+
// Random inputs to stress both emoji path and substitutions.
252+
inputs := []string{
253+
"Hello World!",
254+
" multiple spaces ",
255+
"Æblegrød med fløde",
256+
"foo/bar_baz.qux",
257+
"Mixed—dash–types… and punctuation!!!",
258+
"Edge😀Case🚀❤️",
259+
"____ leading and trailing ____",
260+
"",
261+
}
262+
263+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
264+
defer cancel()
265+
eg, ctx := errgroup.WithContext(ctx)
266+
267+
// Spin up 8 readers.
268+
for range 8 {
269+
eg.Go(func() error {
270+
for {
271+
select {
272+
case <-ctx.Done():
273+
return nil
274+
default:
275+
}
276+
277+
in := pick(inputs)
278+
sep := pick([]string{"", "-", "_", ".", "--"})
279+
out := sl.Slug(in, sep)
280+
281+
// Basic sanity: only ASCII letters/digits and -_. separators, no spaces.
282+
for _, rr := range out {
283+
if rr > unicode.MaxASCII {
284+
return fmt.Errorf("non-ascii rune in slug: %q -> %q", in, out)
285+
}
286+
if unicode.IsSpace(rr) {
287+
return fmt.Errorf("space in slug: %q -> %q", in, out)
288+
}
289+
}
290+
}
291+
})
292+
}
293+
294+
// Spin up 4 writers.
295+
for range 4 {
296+
eg.Go(func() error {
297+
keys := []string{"hello", "😀", "æ", "foo", "bar", "baz", "qux", "quux"}
298+
299+
for {
300+
select {
301+
case <-ctx.Done():
302+
return nil
303+
default:
304+
}
305+
306+
switch intN(4) {
307+
case 0: // Add
308+
k := pick(keys) + time.Now().Format("150405.000")
309+
sl.AddSubstitution(k, pick(keys))
310+
case 1: // Remove
311+
sl.RemoveSubstitution(pick(keys))
312+
case 2: // Replace
313+
sl.ReplaceSubstitution(pick(keys), pick(keys))
314+
case 3: // Replace all
315+
n := 1 + intN(4)
316+
m := make(map[string]string, n)
317+
for range n {
318+
m[pick(keys)] = keys[intN(len(keys))]
319+
}
320+
sl.SetSubstitutions(m)
321+
}
322+
323+
// Random sleep to avoid contention.
324+
time.Sleep(time.Duration(intN(3)) * time.Millisecond)
325+
}
326+
})
327+
}
328+
329+
// Wait for completion (either timeout or earlier failure).
330+
if err := eg.Wait(); err != nil {
331+
t.Fatal(err)
332+
}
333+
}
334+
207335
func BenchmarkSlugger_Slug(b *testing.B) {
208336
sl := New(map[string]string{"&": "and"}, false)
209337

0 commit comments

Comments
 (0)