Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jobs:
uses: marocchino/sticky-pull-request-comment@v2
if: github.event_name == 'pull_request'
with:
header: coverage-report
recreate: true
path: coverage_summary.md

Expand Down
88 changes: 88 additions & 0 deletions sx.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"slices"
"strings"
"unicode"
"unicode/utf8"
)

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

return splitByCaseWithCustomSeparators(s, config.Separators)
}

// normalizeWord normalizes a word's case if needed
func normalizeWord(word string, normalize bool) string {
if normalize {
return strings.ToLower(word)
}
return word
}

// capitalizeWord capitalizes the first letter of a word
func capitalizeWord(word string) string {
if word == "" {
return word
}

r, size := utf8.DecodeRuneInString(word)
if size == 0 {
return word
}

return string(unicode.ToUpper(r)) + word[size:]
}

// joinWords joins words with a separator
func joinWords(words []string, separator string, transform func(string, int) string) string {
if len(words) == 0 {
return ""
}

var result strings.Builder
for i, word := range words {
if i > 0 && separator != "" {
result.WriteString(separator)
}
result.WriteString(transform(word, i))
}

return result.String()
}

type CaseOption func(*CaseConfig)

// CaseConfig configures case conversion behavior
type CaseConfig struct {
// 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.
Normalize bool
}

// WithNormalize sets the normalize option
func WithNormalize(normalize bool) CaseOption {
return func(c *CaseConfig) {
c.Normalize = normalize
}
}

// StringOrStringSlice represents input that can be either a string or slice of strings
type StringOrStringSlice interface {
string | []string
}

// PascalCase converts input to PascalCase
func PascalCase[T StringOrStringSlice](input T, opts ...CaseOption) string {
options := CaseConfig{}
for _, opt := range opts {
opt(&options)
}

switch v := any(input).(type) {
case string:
words := splitByCaseWithCustomSeparators(v, nil)
result := joinWords(words, "", func(word string, i int) string {
normalized := normalizeWord(word, options.Normalize)
return capitalizeWord(normalized)
})

return result
case []string:
result := joinWords(v, "", func(word string, i int) string {
normalized := normalizeWord(word, options.Normalize)
return capitalizeWord(normalized)
})

return result
default:
return ""
}
}
105 changes: 105 additions & 0 deletions sx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,108 @@ func TestSplitByCase_CustomSeparators(t *testing.T) {
})
}
}

func TestPascalCase(t *testing.T) {
tests := []struct {
name string
input string
expected string
options []sx.CaseOption
}{
{
name: "camelCase to PascalCase",
input: "camelCase",
expected: "CamelCase",
},
{
name: "kebab-case to PascalCase",
input: "kebab-case",
expected: "KebabCase",
},
{
name: "snake_case to PascalCase",
input: "snake_case",
expected: "SnakeCase",
},
{
name: "mixed.case_with-separators",
input: "mixed.case_with-separators",
expected: "MixedCaseWithSeparators",
},
{
name: "XMLHttpRequest",
input: "XMLHttpRequest",
expected: "XMLHttpRequest",
},
{
name: "XMLHttpRequest normalized",
input: "XMLHttpRequest",
expected: "XmlHttpRequest",
options: []sx.CaseOption{sx.WithNormalize(true)},
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "single word",
input: "word",
expected: "Word",
},
{
name: "hello--world-42",
input: "hello--world-42",
expected: "HelloWorld42",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sx.PascalCase(tt.input, tt.options...)
if result != tt.expected {
t.Errorf("PascalCase(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

func TestPascalCaseWithSlice(t *testing.T) {
tests := []struct {
name string
input []string
expected string
options []sx.CaseOption
}{
{
name: "string slice",
input: []string{"hello", "world", "test"},
expected: "HelloWorldTest",
},
{
name: "string slice normalized",
input: []string{"HELLO", "WORLD", "TEST"},
expected: "HelloWorldTest",
options: []sx.CaseOption{sx.WithNormalize(true)},
},
{
name: "empty slice",
input: []string{},
expected: "",
},
{
name: "single item slice",
input: []string{"word"},
expected: "Word",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sx.PascalCase(tt.input, tt.options...)
if result != tt.expected {
t.Errorf("PascalCase(%v) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
Loading