diff --git a/autopatch/autopatch.go b/autopatch/autopatch.go index c189cc85..37b3171d 100644 --- a/autopatch/autopatch.go +++ b/autopatch/autopatch.go @@ -18,8 +18,8 @@ import ( "strconv" "strings" - "github.com/danielgtaylor/casing" "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/casing" "github.com/danielgtaylor/shorthand/v2" jsonpatch "github.com/evanphx/json-patch/v5" ) diff --git a/casing/casing.go b/casing/casing.go new file mode 100644 index 00000000..d9795a42 --- /dev/null +++ b/casing/casing.go @@ -0,0 +1,320 @@ +// Package casing helps convert between CamelCase, snake_case, and others. It +// includes an intelligent `Split` function to take almost any input and then +// convert it to any type of casing. +package casing + +import ( + "strconv" + "strings" + "unicode" +) + +// Borrowed from Go lint (https://github.com/golang/lint) +var commonInitialisms = map[string]bool{ + "ACL": true, + "API": true, + "ASCII": true, + "CPU": true, + "CSS": true, + "DNS": true, + "EOF": true, + "GUID": true, + "HTML": true, + "HTTP": true, + "HTTPS": true, + "ID": true, + "IP": true, + "JSON": true, + "LHS": true, + "QPS": true, + "RAM": true, + "RHS": true, + "RPC": true, + "SLA": true, + "SMTP": true, + "SQL": true, + "SSH": true, + "TCP": true, + "TLS": true, + "TTL": true, + "UDP": true, + "UI": true, + "UID": true, + "UUID": true, + "URI": true, + "URL": true, + "UTF8": true, + "VM": true, + "XML": true, + "XMPP": true, + "XSRF": true, + "XSS": true, + + // Media initialisms + "1080P": true, + "2D": true, + "3D": true, + "4K": true, + "8K": true, + "AAC": true, + "AC3": true, + "CDN": true, + "DASH": true, + "DRM": true, + "DVR": true, + "EAC3": true, + "FPS": true, + "GOP": true, + "H264": true, + "H265": true, + "HD": true, + "HLS": true, + "MJPEG": true, + "MP2T": true, + "MP3": true, + "MP4": true, + "MPEG2": true, + "MPEG4": true, + "NTSC": true, + "PCM": true, + "RGB": true, + "RGBA": true, + "RTMP": true, + "RTP": true, + "SCTE": true, + "SCTE35": true, + "SMPTE": true, + "UPID": true, + "UPIDS": true, + "VOD": true, + "YUV420": true, + "YUV422": true, + "YUV444": true, +} + +var commonSuffixes = map[string]bool{ + // E.g. 2D, 3D + "D": true, + // E.g. 100GB + "GB": true, + // E.g. 4K, 8K + "K": true, + // E.g. 100KB + "KB": true, + // E.g. 64kbps + "KBPS": true, + // E.g. 100MB + "MB": true, + // E.g. 2500mbps + "MPBS": true, + // E.g. 1080P + "P": true, + // E.g. 100TB + "TB": true, +} + +// TransformFunc is used to transform parts of a split string during joining. +type TransformFunc func(string) string + +// Identity is the identity transformation function. +func Identity(part string) string { + return part +} + +// Initialism converts common initialisms like ID and HTTP to uppercase. +func Initialism(part string) string { + if u := strings.ToUpper(part); commonInitialisms[u] { + return u + } + return part +} + +// State machine for splitting. +const ( + stateNone = 0 + stateLower = 1 + stateFirstUpper = 2 + stateUpper = 3 + stateSymbol = 4 +) + +// Split a value intelligently, taking into account different casing styles, +// numbers, symbols, etc. +// +// // Returns: ["HTTP", "Server", "2020"] +// casing.Split("HTTPServer_2020") +func Split(value string) []string { + results := []string{} + start := 0 + state := stateNone + + for i, c := range value { + // Regardless of state, these always break words. Handles kabob and snake + // casing, respectively. + if unicode.IsSpace(c) || unicode.IsPunct(c) { + if i-start > 0 { + results = append(results, value[start:i]) + } + start = i + 1 + state = stateNone + continue + } + + switch { + case state != stateFirstUpper && state != stateUpper && unicode.IsUpper(c): + // Initial uppercase, might start a word, e.g. Camel + if start != i { + results = append(results, value[start:i]) + start = i + } + state = stateFirstUpper + case state == stateFirstUpper && unicode.IsUpper(c): + // String of uppercase to be grouped, e.g. HTTP + state = stateUpper + case state != stateSymbol && !unicode.IsLetter(c): + // Anything -> non-letter + if start != i { + results = append(results, value[start:i]) + start = i + } + state = stateSymbol + case state != stateLower && unicode.IsLower(c): + if state == stateUpper { + // Multi-character uppercase to lowercase. Last item in the uppercase + // is part of the lowercase string, e.g. HTTPServer. + if i > 0 && start != i-1 { + results = append(results, value[start:i-1]) + start = i - 1 + } + } else if state != stateFirstUpper { + // End of a non-uppercase or non-lowercase string. Ignore the first + // upper state as it's part of the same word. + if i > 0 && start != i { + results = append(results, value[start:i]) + start = i + } + } + state = stateLower + } + } + + // Include whatever is at the end of the string. + if start < len(value) { + results = append(results, value[start:]) + } + + return results +} + +// Join will combine split parts back together with the given separator and +// optional transform functions. +func Join(parts []string, sep string, transform ...TransformFunc) string { + for i := 0; i < len(parts); i++ { + for _, t := range transform { + parts[i] = t(parts[i]) + + if parts[i] == "" { + // Transformer completely removed this part. + parts = append(parts[:i], parts[i+1:]...) + i-- + } + } + } + + return strings.Join(parts, sep) +} + +// MergeNumbers will merge some number parts with their adjacent letter parts +// to support a smarter delimited join. For example, `h264` instead of `h_264` +// or `mp3-player` instead of `mp-3-player`. You can pass suffixes for right +// aligning certain items, e.g. `K` to get `MODE_4K` instead of `MODE4_K`. If +// no suffixes are passed, then a default set of common ones is used. Pass an +// empty string to disable the default. +func MergeNumbers(parts []string, suffixes ...string) []string { + // TODO: should we do this in-place instead? + results := make([]string, 0, len(parts)) + prevNum := false + + suffixLookup := map[string]bool{} + for _, word := range suffixes { + suffixLookup[strings.ToUpper(word)] = true + } + if len(suffixes) == 0 { + suffixLookup = commonSuffixes + } + + for i := 0; i < len(parts); i++ { + part := parts[i] + if _, err := strconv.Atoi(part); err == nil { + // This part is a number! + + // Special case: right aligned word + if i < len(parts)-1 && suffixLookup[strings.ToUpper(parts[i+1])] { + results = append(results, part+parts[i+1]) + i++ + continue + } + + if !prevNum { + if i == 0 { + // First item must always append. + results = append(results, part) + } else { + // Concatenate the number to the previous non-number piece. + results[len(results)-1] += part + } + prevNum = true + continue + } + + prevNum = true + } else { + // Special case: first part is a number, second part is not. + if i == 1 && prevNum { + results[0] += part + prevNum = false + continue + } + + prevNum = false + } + + results = append(results, part) + } + + return results +} + +// Camel returns a CamelCase version of the input. If no transformation +// functions are passed in, then strings.ToLower is used. This can be disabled +// by passing in the identity function. +func Camel(value string, transform ...TransformFunc) string { + if transform == nil { + transform = []TransformFunc{strings.ToLower} + } + t := append(transform, strings.Title) //nolint:staticcheck + return Join(Split(value), "", t...) +} + +// LowerCamel returns a lowerCamelCase version of the input. +func LowerCamel(value string, transform ...TransformFunc) string { + runes := []rune(Camel(value, transform...)) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + +// Snake returns a snake_case version of the input. +func Snake(value string, transform ...TransformFunc) string { + if transform == nil { + transform = []TransformFunc{strings.ToLower} + } + return Join(MergeNumbers(Split(value)), "_", transform...) +} + +// Kebab returns a kabob-case version of the input. +func Kebab(value string, transform ...TransformFunc) string { + if transform == nil { + transform = []TransformFunc{strings.ToLower} + } + return Join(MergeNumbers(Split(value)), "-", transform...) +} diff --git a/casing/casing_test.go b/casing/casing_test.go new file mode 100644 index 00000000..101fb55d --- /dev/null +++ b/casing/casing_test.go @@ -0,0 +1,97 @@ +package casing_test + +import ( + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2/casing" + "github.com/stretchr/testify/assert" +) + +func TestSplit(tt *testing.T) { + tests := []struct { + Input string + Expected []string + }{ + // Expected inputs based on different casings + {"CamelCaseTest", []string{"Camel", "Case", "Test"}}, + {"lowerCamelTest", []string{"lower", "Camel", "Test"}}, + {"snake_case_test", []string{"snake", "case", "test"}}, + {"kabob-case-test", []string{"kabob", "case", "test"}}, + {"Space delimited test", []string{"Space", "delimited", "test"}}, + + // Possible weird edge cases + {"AnyKind of_string", []string{"Any", "Kind", "of", "string"}}, + {"hello__man how-Are you??", []string{"hello", "man", "how", "Are", "you"}}, + {"UserID", []string{"User", "ID"}}, + {"HTTPServer", []string{"HTTP", "Server"}}, + {"Test123Test", []string{"Test", "123", "Test"}}, + {"Test123test", []string{"Test", "123", "test"}}, + {"Dupe-_---test", []string{"Dupe", "test"}}, + {"ÜberWürsteÄußerst", []string{"Über", "Würste", "Äußerst"}}, + {"MakeAWish", []string{"Make", "A", "Wish"}}, + {"uHTTP123", []string{"u", "HTTP", "123"}}, + {"aB1-1Ba", []string{"a", "B", "1", "1", "Ba"}}, + {"a.bc.d", []string{"a", "bc", "d"}}, + {"Emojis 🎉🎊-🎈", []string{"Emojis", "🎉🎊", "🎈"}}, + {"a b c", []string{"a", "b", "c"}}, + {"1 2 3", []string{"1", "2", "3"}}, + } + + for _, test := range tests { + tt.Run(test.Input, func(t *testing.T) { + assert.Equal(t, test.Expected, casing.Split(test.Input)) + }) + } +} + +func TestCamelCases(t *testing.T) { + assert.Equal(t, "CamelCaseTEST", casing.Camel("camel_case_TEST", casing.Identity)) + assert.Equal(t, "CamelCaseTest", casing.Camel("camel_case_TEST")) + + assert.Equal(t, "lowerCamelCaseTEST", casing.LowerCamel("lower_camel_case_TEST", casing.Identity)) + assert.Equal(t, "lowerCamelCaseTest", casing.LowerCamel("lower_camel_case_TEST")) + + // Multi-byte characters should properly lowercase when starting a string. + assert.Equal(t, "überStraße", casing.LowerCamel("ÜberStraße")) +} + +func TestSnakeCase(t *testing.T) { + assert.Equal(t, "Snake_Case_TEST", casing.Snake("SnakeCaseTEST", casing.Identity)) + assert.Equal(t, "snake_case_test", casing.Snake("SnakeCaseTEST")) + assert.Equal(t, "unsinn_überall", casing.Snake("UnsinnÜberall")) + + // Number merging logic for nicer names. + assert.Equal(t, "mp4", casing.Snake("mp4")) + assert.Equal(t, "h264_stream", casing.Snake("h.264 stream")) + assert.Equal(t, "foo1_23", casing.Snake("Foo1-23")) + assert.Equal(t, "1stop", casing.Snake("1 stop")) +} + +func TestKebabCase(t *testing.T) { + assert.Equal(t, "Kebab-Case-TEST", casing.Kebab("KebabCaseTEST", casing.Identity)) + assert.Equal(t, "kebab-case-test", casing.Kebab("KebabCaseTEST")) +} + +func TestInitialism(t *testing.T) { + // For example: convert any input to public Go variable name. + assert.Equal(t, "UserID", casing.Camel("USER_ID", strings.ToLower, casing.Initialism)) + assert.Equal(t, "PlatformAPI", casing.Camel("platform-api", casing.Initialism)) +} + +func TestRemovePart(t *testing.T) { + assert.Equal(t, "one_two", casing.Snake("one-and-two-and", func(part string) string { + if part == "and" { + return "" + } + + return part + })) +} + +func TestRightAlign(t *testing.T) { + assert.Equal(t, "stream_1080p", casing.Snake("Stream1080P")) + + // Custom align words + assert.Equal(t, "test_123foo", casing.Join(casing.MergeNumbers(casing.Split("test 123 foo"), "FOO"), "_")) +} diff --git a/go.mod b/go.mod index 55af4b2a..846b815d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/danielgtaylor/huma/v2 go 1.20 require ( - github.com/danielgtaylor/casing v1.0.0 github.com/danielgtaylor/shorthand/v2 v2.2.0 github.com/evanphx/json-patch/v5 v5.9.0 github.com/fxamacker/cbor/v2 v2.6.0 diff --git a/go.sum b/go.sum index 1178db9a..d78defe4 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,6 @@ github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0 github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/danielgtaylor/casing v1.0.0 h1:uX+PewTv0zbXeTluwRwlyPMRQEduVP9svLHpbDsQYkw= -github.com/danielgtaylor/casing v1.0.0/go.mod h1:eFdYmNxcuLDrRNW0efVoxSaApmvGXfHZ9k2CT/RSUF0= github.com/danielgtaylor/mexpr v1.9.0 h1:9ZDghCLBJ88ZTUkDn/cxyK4KmAJvStCEe+ECN2EoMa4= github.com/danielgtaylor/mexpr v1.9.0/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8= github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ= diff --git a/huma.go b/huma.go index c439df87..085053ca 100644 --- a/huma.go +++ b/huma.go @@ -23,7 +23,7 @@ import ( "sync" "time" - "github.com/danielgtaylor/casing" + "github.com/danielgtaylor/huma/v2/casing" ) var errDeadlineUnsupported = fmt.Errorf("%w", http.ErrNotSupported) diff --git a/humacli/humacli.go b/humacli/humacli.go index 721a1d36..9db7f8a4 100644 --- a/humacli/humacli.go +++ b/humacli/humacli.go @@ -12,7 +12,7 @@ import ( "syscall" "time" - "github.com/danielgtaylor/casing" + "github.com/danielgtaylor/huma/v2/casing" "github.com/spf13/cobra" )