diff --git a/acronyms.go b/acronyms.go index 3b06064..ecb533e 100644 --- a/acronyms.go +++ b/acronyms.go @@ -1,5 +1,5 @@ package strcase -var uppercaseAcronym = map[string]bool{ - "ID": true, +var uppercaseAcronym = map[string]string{ + "ID": "id", } diff --git a/camel.go b/camel.go index 4aab39a..f31646e 100644 --- a/camel.go +++ b/camel.go @@ -31,51 +31,49 @@ import ( // Converts a string to CamelCase func toCamelInitCase(s string, initCase bool) string { - s = addWordBoundariesToNumbers(s) - s = strings.Trim(s, " ") - n := "" + if s == "" { + return s + } + if a, ok := uppercaseAcronym[s]; ok { + s = a + } + + n := strings.Builder{} + n.Grow(len(s)) capNext := initCase - for _, v := range s { - if v >= 'A' && v <= 'Z' { - n += string(v) - } - if v >= '0' && v <= '9' { - n += string(v) - } - if v >= 'a' && v <= 'z' { - if capNext { - n += strings.ToUpper(string(v)) - } else { - n += string(v) + for i, v := range []byte(s) { + vIsCap := v >= 'A' && v <= 'Z' + vIsLow := v >= 'a' && v <= 'z' + if capNext { + if vIsLow { + v += 'A' + v -= 'a' + } + } else if i == 0 { + if vIsCap { + v += 'a' + v -= 'A' } } - if v == '_' || v == ' ' || v == '-' || v == '.' { + if vIsCap || vIsLow { + n.WriteByte(v) + capNext = false + } else if vIsNum := v >= '0' && v <= '9'; vIsNum { + n.WriteByte(v) capNext = true } else { - capNext = false + capNext = v == '_' || v == ' ' || v == '-' || v == '.' } } - return n + return n.String() } // ToCamel converts a string to CamelCase func ToCamel(s string) string { - if uppercaseAcronym[s] { - s = strings.ToLower(s) - } return toCamelInitCase(s, true) } // ToLowerCamel converts a string to lowerCamelCase func ToLowerCamel(s string) string { - if s == "" { - return s - } - if uppercaseAcronym[s] { - s = strings.ToLower(s) - } - if r := rune(s[0]); r >= 'A' && r <= 'Z' { - s = strings.ToLower(string(r)) + s[1:] - } return toCamelInitCase(s, false) } diff --git a/camel_test.go b/camel_test.go index e82e53d..bacf8f7 100644 --- a/camel_test.go +++ b/camel_test.go @@ -28,7 +28,7 @@ import ( "testing" ) -func TestToCamel(t *testing.T) { +func toCamel(tb testing.TB) { cases := [][]string{ {"test_case", "TestCase"}, {"test.case", "TestCase"}, @@ -47,12 +47,20 @@ func TestToCamel(t *testing.T) { out := i[1] result := ToCamel(in) if result != out { - t.Error("'" + result + "' != '" + out + "'") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToLowerCamel(t *testing.T) { +func TestToCamel(t *testing.T) { + toCamel(t) +} + +func BenchmarkToCamel(b *testing.B) { + benchmarkCamelTest(b, toCamel) +} + +func toLowerCamel(tb testing.TB) { cases := [][]string{ {"foo-bar", "fooBar"}, {"TestCase", "testCase"}, @@ -66,7 +74,21 @@ func TestToLowerCamel(t *testing.T) { out := i[1] result := ToLowerCamel(in) if result != out { - t.Error("'" + result + "' != '" + out + "'") + tb.Errorf("%q (%q != %q)", in, result, out) } } } + +func TestToLowerCamel(t *testing.T) { + toLowerCamel(t) +} + +func BenchmarkToLowerCamel(b *testing.B) { + benchmarkCamelTest(b, toLowerCamel) +} + +func benchmarkCamelTest(b *testing.B, fn func(testing.TB)) { + for n := 0; n < b.N; n++ { + fn(b) + } +} diff --git a/numbers.go b/numbers.go deleted file mode 100644 index fdf07cb..0000000 --- a/numbers.go +++ /dev/null @@ -1,38 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 Ian Coleman - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, Subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or Substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package strcase - -import ( - "regexp" -) - -var numberSequence = regexp.MustCompile(`([a-zA-Z])(\d+)([a-zA-Z]?)`) -var numberReplacement = []byte(`$1 $2 $3`) - -func addWordBoundariesToNumbers(s string) string { - b := []byte(s) - b = numberSequence.ReplaceAll(b, numberReplacement) - return string(b) -} diff --git a/snake.go b/snake.go index 8e8867b..b849a58 100644 --- a/snake.go +++ b/snake.go @@ -31,11 +31,10 @@ import ( // ToSnake converts a string to snake_case func ToSnake(s string) string { - return ToDelimited(s, '_') } -func ToSnakeWithIgnore(s string, ignore uint8) string { +func ToSnakeWithIgnore(s string, ignore uint8) string { return ToScreamingDelimited(s, '_', ignore, false) } @@ -65,49 +64,68 @@ func ToDelimited(s string, delimiter uint8) string { // or delimited.snake.case // (in this case `delimiter = '.'; screaming = false`) func ToScreamingDelimited(s string, delimiter uint8, ignore uint8, screaming bool) string { - s = addWordBoundariesToNumbers(s) - s = strings.Trim(s, " ") - n := "" - for i, v := range s { + n := strings.Builder{} + n.Grow(len(s) + 2) // nominal 2 bytes of extra space for inserted delimiters + start := true + spaces := 0 + for i, v := range []byte(s) { + if v == ' ' { + spaces++ + continue + } else if start { + start = false + spaces = 0 + } else { + for ; spaces > 0; spaces-- { + if ignore == ' ' { + n.WriteByte(' ') + } else { + n.WriteByte(delimiter) + } + } + } + + vIsCap := v >= 'A' && v <= 'Z' + vIsLow := v >= 'a' && v <= 'z' + if vIsLow && screaming { + v += 'A' + v -= 'a' + } else if vIsCap && !screaming { + v += 'a' + v -= 'A' + } + // treat acronyms as words, eg for JSONData -> JSON is a whole word - nextCaseIsChanged := false if i+1 < len(s) { next := s[i+1] - vIsCap := v >= 'A' && v <= 'Z' - vIsLow := v >= 'a' && v <= 'z' + vIsNum := v >= '0' && v <= '9' nextIsCap := next >= 'A' && next <= 'Z' nextIsLow := next >= 'a' && next <= 'z' - if (vIsCap && nextIsLow) || (vIsLow && nextIsCap) { - nextCaseIsChanged = true - } - if ignore > 0 && i-1 >= 0 && s[i-1] == ignore && nextCaseIsChanged { - nextCaseIsChanged = false - } - } - - if i > 0 && n[len(n)-1] != delimiter && nextCaseIsChanged { + nextIsNum := next >= '0' && next <= '9' // add underscore if next letter case type is changed - if v >= 'A' && v <= 'Z' { - n += string(delimiter) + string(v) - } else if v >= 'a' && v <= 'z' { - n += string(v) + string(delimiter) - } - } else if v == ' ' || v == '_' || v == '-' { - // replace spaces/underscores with delimiters - if uint8(v) == ignore { - n += string(v) - } else { - n += string(delimiter) + if (vIsCap && (nextIsLow || nextIsNum)) || (vIsLow && (nextIsCap || nextIsNum)) || (vIsNum && (nextIsCap || nextIsLow)) { + if prevIgnore := ignore > 0 && i > 0 && s[i-1] == ignore; !prevIgnore { + if vIsCap && nextIsLow { + if prevIsCap := i > 0 && s[i-1] >= 'A' && s[i-1] <= 'Z'; prevIsCap { + n.WriteByte(delimiter) + } + } + n.WriteByte(v) + if vIsLow || vIsNum || nextIsNum { + n.WriteByte(delimiter) + } + continue + } } + } + + if (v == ' ' || v == '_' || v == '-') && uint8(v) != ignore { + // replace space/underscore/hyphen with delimiter + n.WriteByte(delimiter) } else { - n = n + string(v) + n.WriteByte(v) } } - if screaming { - n = strings.ToUpper(n) - } else { - n = strings.ToLower(n) - } - return n + return n.String() } diff --git a/snake_test.go b/snake_test.go index 92f1249..2e34372 100644 --- a/snake_test.go +++ b/snake_test.go @@ -29,7 +29,7 @@ import ( "testing" ) -func TestToSnake(t *testing.T) { +func toSnake(tb testing.TB) { cases := [][]string{ {"testCase", "test_case"}, {"TestCase", "test_case"}, @@ -48,17 +48,30 @@ func TestToSnake(t *testing.T) { {"JSONData", "json_data"}, {"userID", "user_id"}, {"AAAbbb", "aa_abbb"}, + {"1A2", "1_a_2"}, + {"A1B", "a_1_b"}, + {"A1A2A3", "a_1_a_2_a_3"}, + {"A1 A2 A3", "a_1_a_2_a_3"}, + {"AB1AB2AB3", "ab_1_ab_2_ab_3"}, + {"AB1 AB2 AB3", "ab_1_ab_2_ab_3"}, } for _, i := range cases { in := i[0] out := i[1] result := ToSnake(in) if result != out { - t.Error("'" + in + "'('" + result + "' != '" + out + "')") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToSnakeWithIgnore(t *testing.T) { + +func TestToSnake(t *testing.T) { toSnake(t) } + +func BenchmarkToSnake(b *testing.B) { + benchmarkSnakeTest(b, toSnake) +} + +func toSnakeWithIgnore(tb testing.TB) { cases := [][]string{ {"testCase", "test_case"}, {"TestCase", "test_case"}, @@ -75,9 +88,9 @@ func TestToSnakeWithIgnore(t *testing.T) { {"AnyKind of_string", "any_kind_of_string"}, {"numbers2and55with000", "numbers_2_and_55_with_000"}, {"JSONData", "json_data"}, - {"AwesomeAcitvity.UserID", "awesome_acitvity.user_id", "."}, - {"AwesomeAcitvity.User.Id", "awesome_acitvity.user.id", "."}, - {"AwesomeUsername@Awesome.Com", "awesome_username@awesome._com", "@"}, + {"AwesomeActivity.UserID", "awesome_activity.user_id", "."}, + {"AwesomeActivity.User.Id", "awesome_activity.user.id", "."}, + {"AwesomeUsername@Awesome.Com", "awesome_username@awesome.com", "@"}, } for _, i := range cases { in := i[0] @@ -88,12 +101,22 @@ func TestToSnakeWithIgnore(t *testing.T) { } result := ToSnakeWithIgnore(in, ignore) if result != out { - t.Error("'" + in + "'('" + result + "' != '" + out + "')") + istr := "" + if len(i) == 3 { + istr = " ignoring '" + i[2] + "'" + } + tb.Errorf("%q (%q != %q%s)", in, result, out, istr) } } } -func TestToDelimited(t *testing.T) { +func TestToSnakeWithIgnore(t *testing.T) { toSnakeWithIgnore(t) } + +func BenchmarkToSnakeWithIgnore(b *testing.B) { + benchmarkSnakeTest(b, toSnakeWithIgnore) +} + +func toDelimited(tb testing.TB) { cases := [][]string{ {"testCase", "test@case"}, {"TestCase", "test@case"}, @@ -119,12 +142,18 @@ func TestToDelimited(t *testing.T) { out := i[1] result := ToDelimited(in, '@') if result != out { - t.Error("'" + in + "' ('" + result + "' != '" + out + "')") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToScreamingSnake(t *testing.T) { +func TestToDelimited(t *testing.T) { toDelimited(t) } + +func BenchmarkToDelimited(b *testing.B) { + benchmarkSnakeTest(b, toDelimited) +} + +func toScreamingSnake(tb testing.TB) { cases := [][]string{ {"testCase", "TEST_CASE"}, } @@ -133,12 +162,18 @@ func TestToScreamingSnake(t *testing.T) { out := i[1] result := ToScreamingSnake(in) if result != out { - t.Error("'" + result + "' != '" + out + "'") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToKebab(t *testing.T) { +func TestToScreamingSnake(t *testing.T) { toScreamingSnake(t) } + +func BenchmarkToScreamingSnake(b *testing.B) { + benchmarkSnakeTest(b, toScreamingSnake) +} + +func toKebab(tb testing.TB) { cases := [][]string{ {"testCase", "test-case"}, } @@ -147,12 +182,18 @@ func TestToKebab(t *testing.T) { out := i[1] result := ToKebab(in) if result != out { - t.Error("'" + result + "' != '" + out + "'") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToScreamingKebab(t *testing.T) { +func TestToKebab(t *testing.T) { toKebab(t) } + +func BenchmarkToKebab(b *testing.B) { + benchmarkSnakeTest(b, toKebab) +} + +func toScreamingKebab(tb testing.TB) { cases := [][]string{ {"testCase", "TEST-CASE"}, } @@ -161,12 +202,18 @@ func TestToScreamingKebab(t *testing.T) { out := i[1] result := ToScreamingKebab(in) if result != out { - t.Error("'" + result + "' != '" + out + "'") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToScreamingDelimited(t *testing.T) { +func TestToScreamingKebab(t *testing.T) { toScreamingKebab(t) } + +func BenchmarkToScreamingKebab(b *testing.B) { + benchmarkSnakeTest(b, toScreamingKebab) +} + +func toScreamingDelimited(tb testing.TB) { cases := [][]string{ {"testCase", "TEST.CASE"}, } @@ -175,23 +222,45 @@ func TestToScreamingDelimited(t *testing.T) { out := i[1] result := ToScreamingDelimited(in, '.', 0, true) if result != out { - t.Error("'" + result + "' != '" + out + "'") + tb.Errorf("%q (%q != %q)", in, result, out) } } } -func TestToScreamingDelimitedWithIgnore(t *testing.T) { +func TestToScreamingDelimited(t *testing.T) { toScreamingDelimited(t) } + +func BenchmarkToScreamingDelimited(b *testing.B) { + benchmarkSnakeTest(b, toScreamingDelimited) +} + +func toScreamingDelimitedWithIgnore(tb testing.TB) { cases := [][]string{ {"AnyKind of_string", "ANY.KIND OF.STRING", ".", " "}, } for _, i := range cases { in := i[0] out := i[1] - delimiter := uint8(i[2][0]) - ignore := uint8(i[3][0]) + delimiter := i[2][0] + ignore := i[3][0] result := ToScreamingDelimited(in, delimiter, ignore, true) if result != out { - t.Error("'" + result + "' != '" + out + "'") + istr := "" + if len(i) == 4 { + istr = " ignoring '" + i[3] + "'" + } + tb.Errorf("%q (%q != %q%s)", in, result, out, istr) } } } + +func TestToScreamingDelimitedWithIgnore(t *testing.T) { toScreamingDelimitedWithIgnore(t) } + +func BenchmarkToScreamingDelimitedWithIgnore(b *testing.B) { + benchmarkSnakeTest(b, toScreamingDelimitedWithIgnore) +} + +func benchmarkSnakeTest(b *testing.B, fn func(testing.TB)) { + for n := 0; n < b.N; n++ { + fn(b) + } +}