diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c7b86..2f9cd41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/sx.go b/sx.go index e2dbc69..9289f0c 100644 --- a/sx.go +++ b/sx.go @@ -4,6 +4,7 @@ import ( "slices" "strings" "unicode" + "unicode/utf8" ) // Common separators used for splitting strings @@ -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 "" + } +} diff --git a/sx_test.go b/sx_test.go index 8852088..7d763d3 100644 --- a/sx_test.go +++ b/sx_test.go @@ -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) + } + }) + } +}