Skip to content

Commit 1fdafe3

Browse files
authored
add PascalCase (#1)
1 parent f483b2f commit 1fdafe3

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ jobs:
7777
uses: marocchino/sticky-pull-request-comment@v2
7878
if: github.event_name == 'pull_request'
7979
with:
80+
header: coverage-report
8081
recreate: true
8182
path: coverage_summary.md
8283

sx.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"slices"
55
"strings"
66
"unicode"
7+
"unicode/utf8"
78
)
89

910
// Common separators used for splitting strings
@@ -144,3 +145,90 @@ func SplitByCase(s string, opts ...SplitOption) []string {
144145

145146
return splitByCaseWithCustomSeparators(s, config.Separators)
146147
}
148+
149+
// normalizeWord normalizes a word's case if needed
150+
func normalizeWord(word string, normalize bool) string {
151+
if normalize {
152+
return strings.ToLower(word)
153+
}
154+
return word
155+
}
156+
157+
// capitalizeWord capitalizes the first letter of a word
158+
func capitalizeWord(word string) string {
159+
if word == "" {
160+
return word
161+
}
162+
163+
r, size := utf8.DecodeRuneInString(word)
164+
if size == 0 {
165+
return word
166+
}
167+
168+
return string(unicode.ToUpper(r)) + word[size:]
169+
}
170+
171+
// joinWords joins words with a separator
172+
func joinWords(words []string, separator string, transform func(string, int) string) string {
173+
if len(words) == 0 {
174+
return ""
175+
}
176+
177+
var result strings.Builder
178+
for i, word := range words {
179+
if i > 0 && separator != "" {
180+
result.WriteString(separator)
181+
}
182+
result.WriteString(transform(word, i))
183+
}
184+
185+
return result.String()
186+
}
187+
188+
type CaseOption func(*CaseConfig)
189+
190+
// CaseConfig configures case conversion behavior
191+
type CaseConfig struct {
192+
// If an uppercase letter is followed by other uppercase letters (like FooBAR), they are preserved. You can use sx.WithNormalize(true) for strictly following PascalCase convention.
193+
Normalize bool
194+
}
195+
196+
// WithNormalize sets the normalize option
197+
func WithNormalize(normalize bool) CaseOption {
198+
return func(c *CaseConfig) {
199+
c.Normalize = normalize
200+
}
201+
}
202+
203+
// StringOrStringSlice represents input that can be either a string or slice of strings
204+
type StringOrStringSlice interface {
205+
string | []string
206+
}
207+
208+
// PascalCase converts input to PascalCase
209+
func PascalCase[T StringOrStringSlice](input T, opts ...CaseOption) string {
210+
options := CaseConfig{}
211+
for _, opt := range opts {
212+
opt(&options)
213+
}
214+
215+
switch v := any(input).(type) {
216+
case string:
217+
words := splitByCaseWithCustomSeparators(v, nil)
218+
result := joinWords(words, "", func(word string, i int) string {
219+
normalized := normalizeWord(word, options.Normalize)
220+
return capitalizeWord(normalized)
221+
})
222+
223+
return result
224+
case []string:
225+
result := joinWords(v, "", func(word string, i int) string {
226+
normalized := normalizeWord(word, options.Normalize)
227+
return capitalizeWord(normalized)
228+
})
229+
230+
return result
231+
default:
232+
return ""
233+
}
234+
}

sx_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,108 @@ func TestSplitByCase_CustomSeparators(t *testing.T) {
169169
})
170170
}
171171
}
172+
173+
func TestPascalCase(t *testing.T) {
174+
tests := []struct {
175+
name string
176+
input string
177+
expected string
178+
options []sx.CaseOption
179+
}{
180+
{
181+
name: "camelCase to PascalCase",
182+
input: "camelCase",
183+
expected: "CamelCase",
184+
},
185+
{
186+
name: "kebab-case to PascalCase",
187+
input: "kebab-case",
188+
expected: "KebabCase",
189+
},
190+
{
191+
name: "snake_case to PascalCase",
192+
input: "snake_case",
193+
expected: "SnakeCase",
194+
},
195+
{
196+
name: "mixed.case_with-separators",
197+
input: "mixed.case_with-separators",
198+
expected: "MixedCaseWithSeparators",
199+
},
200+
{
201+
name: "XMLHttpRequest",
202+
input: "XMLHttpRequest",
203+
expected: "XMLHttpRequest",
204+
},
205+
{
206+
name: "XMLHttpRequest normalized",
207+
input: "XMLHttpRequest",
208+
expected: "XmlHttpRequest",
209+
options: []sx.CaseOption{sx.WithNormalize(true)},
210+
},
211+
{
212+
name: "empty string",
213+
input: "",
214+
expected: "",
215+
},
216+
{
217+
name: "single word",
218+
input: "word",
219+
expected: "Word",
220+
},
221+
{
222+
name: "hello--world-42",
223+
input: "hello--world-42",
224+
expected: "HelloWorld42",
225+
},
226+
}
227+
228+
for _, tt := range tests {
229+
t.Run(tt.name, func(t *testing.T) {
230+
result := sx.PascalCase(tt.input, tt.options...)
231+
if result != tt.expected {
232+
t.Errorf("PascalCase(%q) = %q, want %q", tt.input, result, tt.expected)
233+
}
234+
})
235+
}
236+
}
237+
238+
func TestPascalCaseWithSlice(t *testing.T) {
239+
tests := []struct {
240+
name string
241+
input []string
242+
expected string
243+
options []sx.CaseOption
244+
}{
245+
{
246+
name: "string slice",
247+
input: []string{"hello", "world", "test"},
248+
expected: "HelloWorldTest",
249+
},
250+
{
251+
name: "string slice normalized",
252+
input: []string{"HELLO", "WORLD", "TEST"},
253+
expected: "HelloWorldTest",
254+
options: []sx.CaseOption{sx.WithNormalize(true)},
255+
},
256+
{
257+
name: "empty slice",
258+
input: []string{},
259+
expected: "",
260+
},
261+
{
262+
name: "single item slice",
263+
input: []string{"word"},
264+
expected: "Word",
265+
},
266+
}
267+
268+
for _, tt := range tests {
269+
t.Run(tt.name, func(t *testing.T) {
270+
result := sx.PascalCase(tt.input, tt.options...)
271+
if result != tt.expected {
272+
t.Errorf("PascalCase(%v) = %q, want %q", tt.input, result, tt.expected)
273+
}
274+
})
275+
}
276+
}

0 commit comments

Comments
 (0)