Skip to content

Commit 2a657ad

Browse files
committed
feat: add AddTrailingSlashBytes and AddTrailingSlashString functions with tests for trailing slash handling
1 parent f53137f commit 2a657ad

File tree

6 files changed

+149
-200
lines changed

6 files changed

+149
-200
lines changed

bytes.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,10 @@ func ToUpperBytes(b []byte) []byte {
6363

6464
return b
6565
}
66+
67+
// AddTrailingSlashBytes appends a trailing '/' to b if it does not already end with one.
68+
// If the input already ends with '/', the original slice is returned.
69+
// A new slice is returned when a '/' is appended. The original slice is never modified.
70+
func AddTrailingSlashBytes(b []byte) []byte {
71+
return UnsafeBytes(AddTrailingSlashString(UnsafeString(b)))
72+
}

bytes_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,76 @@ func Test_ToUpperBytes_Edge(t *testing.T) {
105105
})
106106
}
107107
}
108+
109+
func Test_AddTrailingSlashBytes(t *testing.T) {
110+
t.Parallel()
111+
112+
testCases := []struct {
113+
name string
114+
in []byte
115+
want []byte
116+
}{
117+
{name: "empty", in: []byte(""), want: []byte("/")},
118+
{name: "slash-only", in: []byte("/"), want: []byte("/")},
119+
{name: "short-no-slash", in: []byte("abc"), want: []byte("abc/")},
120+
{name: "short-with-slash", in: []byte("abc/"), want: []byte("abc/")},
121+
{name: "path-no-slash", in: []byte("/api/v1/users"), want: []byte("/api/v1/users/")},
122+
{name: "path-with-slash", in: []byte("/api/v1/users/"), want: []byte("/api/v1/users/")},
123+
{name: "double-slash", in: []byte("abc//"), want: []byte("abc//")},
124+
{name: "unicode", in: []byte("/日本語"), want: []byte("/日本語/")},
125+
}
126+
127+
for _, tc := range testCases {
128+
t.Run(tc.name, func(t *testing.T) {
129+
t.Parallel()
130+
result := AddTrailingSlashBytes(tc.in)
131+
require.Equal(t, tc.want, result)
132+
})
133+
}
134+
}
135+
136+
func Test_AddTrailingSlashBytes_NoMutation(t *testing.T) {
137+
t.Parallel()
138+
139+
original := []byte("test")
140+
originalCopy := make([]byte, len(original))
141+
copy(originalCopy, original)
142+
143+
_ = AddTrailingSlashBytes(original)
144+
145+
require.Equal(t, originalCopy, original, "original slice should not be mutated")
146+
}
147+
148+
func Test_AddTrailingSlashBytes_ReturnsSame(t *testing.T) {
149+
t.Parallel()
150+
151+
input := []byte("test/")
152+
result := AddTrailingSlashBytes(input)
153+
require.Equal(t, input, result)
154+
require.Same(t, &input[0], &result[0], "should return same slice instance")
155+
}
156+
157+
func Benchmark_AddTrailingSlashBytes(b *testing.B) {
158+
cases := []struct {
159+
name string
160+
input []byte
161+
}{
162+
{name: "empty", input: []byte("")},
163+
{name: "slash-only", input: []byte("/")},
164+
{name: "short-no-slash", input: []byte("abc")},
165+
{name: "short-with-slash", input: []byte("abc/")},
166+
{name: "path-no-slash", input: []byte("/api/v1/users")},
167+
{name: "path-with-slash", input: []byte("/api/v1/users/")},
168+
}
169+
170+
for _, tc := range cases {
171+
b.Run(tc.name, func(b *testing.B) {
172+
b.ReportAllocs()
173+
var res []byte
174+
for n := 0; n < b.N; n++ {
175+
res = AddTrailingSlashBytes(tc.input)
176+
}
177+
_ = res
178+
})
179+
}
180+
}

byteseq.go

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
package utils
22

3-
import (
4-
"reflect"
5-
"slices"
6-
)
7-
83
type byteSeq interface {
94
~string | ~[]byte
105
}
@@ -114,43 +109,3 @@ func TrimSpace[S byteSeq](s S) S {
114109

115110
return s[i : j+1]
116111
}
117-
118-
// AddTrailingSlash appends a trailing '/' to v if it does not already end with one.
119-
//
120-
// For string inputs, a new string is returned only when a '/' needs to be appended.
121-
// If the input already ends with '/', the original string is returned.
122-
//
123-
// For []byte inputs, a new slice is returned when a '/' is appended.
124-
// If the input already ends with '/', the original slice is returned unchanged.
125-
// The original slice is never modified.
126-
func AddTrailingSlash[T byteSeq](v T) T {
127-
n := len(v)
128-
if n > 0 && v[n-1] == '/' {
129-
return v
130-
}
131-
// Type-specific optimization
132-
switch x := any(v).(type) {
133-
case string:
134-
// For strings: allocate exact size, use UnsafeString to avoid double alloc
135-
buf := make([]byte, n+1)
136-
copy(buf, x)
137-
buf[n] = '/'
138-
return any(UnsafeString(buf)).(T) //nolint:forcetypeassert,errcheck // type is guaranteed
139-
case []byte:
140-
// For bytes: use append which may reuse capacity
141-
return any(append(x, '/')).(T) //nolint:forcetypeassert,errcheck // type is guaranteed
142-
default:
143-
// Fallback for named types (e.g., type MyString string) using reflection
144-
val := reflect.ValueOf(v)
145-
if val.Kind() == reflect.String {
146-
s := val.String() + "/"
147-
return reflect.ValueOf(s).Convert(val.Type()).Interface().(T) //nolint:forcetypeassert,errcheck // type is guaranteed
148-
}
149-
// Assumed to be a slice of bytes
150-
b := val.Bytes()
151-
res := append(slices.Clone(b), '/')
152-
newSlice := reflect.MakeSlice(val.Type(), len(res), len(res))
153-
reflect.Copy(newSlice, reflect.ValueOf(res))
154-
return newSlice.Interface().(T) //nolint:forcetypeassert,errcheck // type is guaranteed
155-
}
156-
}

byteseq_test.go

Lines changed: 0 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -415,158 +415,3 @@ func Benchmark_TrimSpaceBytes(b *testing.B) {
415415
})
416416
}
417417
}
418-
419-
// Shared test cases for AddTrailingSlash benchmarks
420-
var addTrailingSlashBenchmarkCases = []struct {
421-
name string
422-
input string
423-
}{
424-
{name: "empty", input: ""},
425-
{name: "slash-only", input: "/"},
426-
{name: "short-no-slash", input: "abc"},
427-
{name: "short-with-slash", input: "abc/"},
428-
{name: "path-no-slash", input: "/api/v1/users"},
429-
{name: "path-with-slash", input: "/api/v1/users/"},
430-
{name: "long-no-slash", input: "/api/v1/users/profile/settings/notifications"},
431-
{name: "long-with-slash", input: "/api/v1/users/profile/settings/notifications/"},
432-
}
433-
434-
func Test_AddTrailingSlash(t *testing.T) {
435-
t.Parallel()
436-
437-
testCases := []struct {
438-
name string
439-
in string
440-
want string
441-
}{
442-
{name: "empty", in: "", want: "/"},
443-
{name: "slash-only", in: "/", want: "/"},
444-
{name: "short-no-slash", in: "abc", want: "abc/"},
445-
{name: "short-with-slash", in: "abc/", want: "abc/"},
446-
{name: "path-no-slash", in: "/api/v1/users", want: "/api/v1/users/"},
447-
{name: "path-with-slash", in: "/api/v1/users/", want: "/api/v1/users/"},
448-
{name: "double-slash", in: "abc//", want: "abc//"},
449-
{name: "root-path", in: "/", want: "/"},
450-
{name: "spaces", in: " ", want: " /"},
451-
{name: "unicode", in: "/日本語", want: "/日本語/"},
452-
{name: "unicode-with-slash", in: "/日本語/", want: "/日本語/"},
453-
}
454-
455-
for _, tc := range testCases {
456-
tc := tc
457-
t.Run(tc.name+"/string", func(t *testing.T) {
458-
t.Parallel()
459-
result := AddTrailingSlash(tc.in)
460-
require.Equal(t, tc.want, result)
461-
})
462-
t.Run(tc.name+"/bytes", func(t *testing.T) {
463-
t.Parallel()
464-
result := AddTrailingSlash([]byte(tc.in))
465-
require.Equal(t, []byte(tc.want), result)
466-
})
467-
}
468-
}
469-
470-
func Test_AddTrailingSlash_NoMutation(t *testing.T) {
471-
t.Parallel()
472-
473-
// Ensure original byte slice is not mutated
474-
original := []byte("test")
475-
originalCopy := make([]byte, len(original))
476-
copy(originalCopy, original)
477-
478-
_ = AddTrailingSlash(original)
479-
480-
require.Equal(t, originalCopy, original, "original slice should not be mutated")
481-
}
482-
483-
func Test_AddTrailingSlash_AlreadyHasSlash_ReturnsSame(t *testing.T) {
484-
t.Parallel()
485-
486-
// For byte slices with trailing slash, should return the same slice instance
487-
input := []byte("test/")
488-
result := AddTrailingSlash(input)
489-
require.Equal(t, input, result)
490-
require.Same(t, &input[0], &result[0], "should return same slice instance")
491-
492-
// For strings with trailing slash, should return the same string
493-
inputStr := "test/"
494-
resultStr := AddTrailingSlash(inputStr)
495-
require.Equal(t, inputStr, resultStr)
496-
}
497-
498-
func Test_AddTrailingSlash_NamedTypes(t *testing.T) {
499-
t.Parallel()
500-
501-
type MyString string
502-
type MyBytes []byte
503-
504-
t.Run("named string without slash", func(t *testing.T) {
505-
t.Parallel()
506-
result := AddTrailingSlash(MyString("abc"))
507-
require.Equal(t, MyString("abc/"), result)
508-
})
509-
510-
t.Run("named string with slash", func(t *testing.T) {
511-
t.Parallel()
512-
result := AddTrailingSlash(MyString("abc/"))
513-
require.Equal(t, MyString("abc/"), result)
514-
})
515-
516-
t.Run("named bytes without slash", func(t *testing.T) {
517-
t.Parallel()
518-
result := AddTrailingSlash(MyBytes("abc"))
519-
require.Equal(t, MyBytes("abc/"), result)
520-
})
521-
522-
t.Run("named bytes with slash", func(t *testing.T) {
523-
t.Parallel()
524-
result := AddTrailingSlash(MyBytes("abc/"))
525-
require.Equal(t, MyBytes("abc/"), result)
526-
})
527-
528-
t.Run("empty named string", func(t *testing.T) {
529-
t.Parallel()
530-
result := AddTrailingSlash(MyString(""))
531-
require.Equal(t, MyString("/"), result)
532-
})
533-
534-
t.Run("empty named bytes", func(t *testing.T) {
535-
t.Parallel()
536-
result := AddTrailingSlash(MyBytes(""))
537-
require.Equal(t, MyBytes("/"), result)
538-
})
539-
}
540-
541-
func Benchmark_AddTrailingSlash(b *testing.B) {
542-
for _, tc := range addTrailingSlashBenchmarkCases {
543-
tc := tc
544-
b.Run("string/"+tc.name, func(b *testing.B) {
545-
b.ReportAllocs()
546-
b.SetBytes(int64(len(tc.input)))
547-
b.ResetTimer()
548-
var res string
549-
for n := 0; n < b.N; n++ {
550-
res = AddTrailingSlash(tc.input)
551-
}
552-
_ = res
553-
})
554-
}
555-
}
556-
557-
func Benchmark_AddTrailingSlashBytes(b *testing.B) {
558-
for _, tc := range addTrailingSlashBenchmarkCases {
559-
tc := tc
560-
input := []byte(tc.input)
561-
b.Run("bytes/"+tc.name, func(b *testing.B) {
562-
b.ReportAllocs()
563-
b.SetBytes(int64(len(input)))
564-
b.ResetTimer()
565-
var res []byte
566-
for n := 0; n < b.N; n++ {
567-
res = AddTrailingSlash(input)
568-
}
569-
_ = res
570-
})
571-
}
572-
}

strings.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,20 @@ func ToUpper(b string) string {
4848

4949
return b
5050
}
51+
52+
// AddTrailingSlashString appends a trailing '/' to s if it does not already end with one.
53+
// If the input already ends with '/', the original string is returned.
54+
// A new string is returned only when a '/' needs to be appended.
55+
func AddTrailingSlashString(s string) string {
56+
n := len(s)
57+
if n == 0 {
58+
return "/"
59+
}
60+
if s[n-1] == '/' {
61+
return s
62+
}
63+
buf := make([]byte, n+1)
64+
copy(buf, s)
65+
buf[n] = '/'
66+
return UnsafeString(buf)
67+
}

strings_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,55 @@ func Benchmark_ToLower(b *testing.B) {
193193
})
194194
}
195195
}
196+
197+
func Test_AddTrailingSlashString(t *testing.T) {
198+
t.Parallel()
199+
200+
testCases := []struct {
201+
name string
202+
in string
203+
want string
204+
}{
205+
{name: "empty", in: "", want: "/"},
206+
{name: "slash-only", in: "/", want: "/"},
207+
{name: "short-no-slash", in: "abc", want: "abc/"},
208+
{name: "short-with-slash", in: "abc/", want: "abc/"},
209+
{name: "path-no-slash", in: "/api/v1/users", want: "/api/v1/users/"},
210+
{name: "path-with-slash", in: "/api/v1/users/", want: "/api/v1/users/"},
211+
{name: "double-slash", in: "abc//", want: "abc//"},
212+
{name: "unicode", in: "/日本語", want: "/日本語/"},
213+
}
214+
215+
for _, tc := range testCases {
216+
t.Run(tc.name, func(t *testing.T) {
217+
t.Parallel()
218+
result := AddTrailingSlashString(tc.in)
219+
require.Equal(t, tc.want, result)
220+
})
221+
}
222+
}
223+
224+
func Benchmark_AddTrailingSlashString(b *testing.B) {
225+
cases := []struct {
226+
name string
227+
input string
228+
}{
229+
{name: "empty", input: ""},
230+
{name: "slash-only", input: "/"},
231+
{name: "short-no-slash", input: "abc"},
232+
{name: "short-with-slash", input: "abc/"},
233+
{name: "path-no-slash", input: "/api/v1/users"},
234+
{name: "path-with-slash", input: "/api/v1/users/"},
235+
}
236+
237+
for _, tc := range cases {
238+
b.Run(tc.name, func(b *testing.B) {
239+
b.ReportAllocs()
240+
var res string
241+
for n := 0; n < b.N; n++ {
242+
res = AddTrailingSlashString(tc.input)
243+
}
244+
_ = res
245+
})
246+
}
247+
}

0 commit comments

Comments
 (0)