Skip to content

Commit 74e7e83

Browse files
authored
🐛 [casing] Improve plural initialism handling so identifiers such as Urls can be normalised to URLs where appropriate. (#936)
The following PR is to fix two bugs reported on casing: - handling of plural for acronyms - handling of an internal convention of adding `I` in front of interfaces
1 parent 056cf1f commit 74e7e83

12 files changed

Lines changed: 677 additions & 53 deletions

changes/20260707151500.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:bug: Strings/casing: recognise the codebase's `I...` interface naming convention when handling acronym-prefixed identifiers such as `IHTTP`.

changes/20260707151600.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:bug: Strings/casing: improve plural initialism handling so identifiers such as `Urls` can be normalised to `URLs` where appropriate.

utils/strings/casing/case.go

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,66 @@
11
package casing
22

33
import (
4-
"unicode"
4+
"strings"
55

66
"github.com/sttk/stringcase"
77

88
"github.com/ARM-software/golang-utils/utils/collection"
9-
"github.com/ARM-software/golang-utils/utils/reflection"
109
)
1110

1211
// ToCamelCase converts value to camelCase and optionally applies a replacer to the resulting identifier. Only the first replacer is used.
1312
func ToCamelCase(value string, replacers ...*Replacer) string {
14-
result := stringcase.CamelCase(value)
1513
if replacer, ok := collection.First(replacers); ok && replacer != nil {
14+
if normalised, ok := normaliseInterfacePrefixedAcronym(value, replacer, true); ok {
15+
return normalised
16+
}
17+
if isIdentifierWithoutSeparators(value) {
18+
replaced := replaceIdentifierWords(value, replacer, true)
19+
if strings.ToLower(value) == value && isUpperInitialismOrPlural(replaced) {
20+
return strings.ToLower(replaced)
21+
}
22+
return lowerFirstWord(replaced)
23+
}
24+
result := stringcase.CamelCase(prepareCaseInput(value))
1625
return replacer.Replace(result)
1726
}
18-
return result
27+
return stringcase.CamelCase(prepareCaseInput(value))
1928
}
2029

2130
// ToPascalCase converts value to PascalCase and optionally applies a replacer to the resulting identifier. Only the first replacer is used.
2231
func ToPascalCase(value string, replacers ...*Replacer) string {
23-
result := stringcase.PascalCase(value)
2432
if replacer, ok := collection.First(replacers); ok && replacer != nil {
25-
if isIdentifierWithoutSeparators(value) && hasUppercase(value) {
26-
return replacer.Replace(value)
33+
if normalised, ok := normaliseInterfacePrefixedAcronym(value, replacer, false); ok {
34+
return normalised
2735
}
36+
if isIdentifierWithoutSeparators(value) {
37+
return replaceIdentifierWords(value, replacer, false)
38+
}
39+
result := stringcase.PascalCase(prepareCaseInput(value))
2840
return replacer.Replace(result)
2941
}
30-
return result
42+
return stringcase.PascalCase(prepareCaseInput(value))
3143
}
3244

3345
// ToSnakeCase converts value to snake_case and optionally applies a replacer to the identifier before the final case conversion. Only the first replacer is used.
3446
func ToSnakeCase(value string, replacers ...*Replacer) string {
3547
result := value
3648
if replacer, ok := collection.First(replacers); ok && replacer != nil {
37-
result = replacer.Replace(stringcase.PascalCase(value))
49+
if normalised, ok := normaliseInterfacePrefixedAcronym(value, replacer, true); ok {
50+
return strings.ToLower(normalised)
51+
}
52+
if isIdentifierWithoutSeparators(value) {
53+
result = replaceIdentifierWords(value, replacer, startsWithLowercase(value))
54+
} else {
55+
result = replacer.Replace(stringcase.PascalCase(prepareCaseInput(value)))
56+
}
57+
if isInterfacePrefixedAcronym(result) {
58+
return strings.ToLower(result)
59+
}
60+
if parts, ok := splitLeadingLetterCompound(result, replacer); ok {
61+
return formSnakeCasedWords(parts)
62+
}
63+
return formSnakeCasedWords(splitCamelWords(result))
3864
}
3965
return stringcase.SnakeCase(result)
4066
}
@@ -43,20 +69,21 @@ func ToSnakeCase(value string, replacers ...*Replacer) string {
4369
func ToKebabCase(value string, replacers ...*Replacer) string {
4470
result := value
4571
if replacer, ok := collection.First(replacers); ok && replacer != nil {
46-
result = replacer.Replace(stringcase.PascalCase(value))
72+
if normalised, ok := normaliseInterfacePrefixedAcronym(value, replacer, true); ok {
73+
return strings.ToLower(normalised)
74+
}
75+
if isIdentifierWithoutSeparators(value) {
76+
result = replaceIdentifierWords(value, replacer, startsWithLowercase(value))
77+
} else {
78+
result = replacer.Replace(stringcase.PascalCase(prepareCaseInput(value)))
79+
}
80+
if isInterfacePrefixedAcronym(result) {
81+
return strings.ToLower(result)
82+
}
83+
if parts, ok := splitLeadingLetterCompound(result, replacer); ok {
84+
return formKebabCasedWords(parts)
85+
}
86+
return formKebabCasedWords(splitCamelWords(result))
4787
}
4888
return stringcase.KebabCase(result)
4989
}
50-
51-
func isIdentifierWithoutSeparators(value string) bool {
52-
if reflection.IsEmpty(value) {
53-
return false
54-
}
55-
return collection.AllFunc([]rune(value), func(r rune) bool {
56-
return unicode.IsLetter(r) || unicode.IsDigit(r)
57-
})
58-
}
59-
60-
func hasUppercase(value string) bool {
61-
return collection.AnyFunc([]rune(value), unicode.IsUpper)
62-
}

utils/strings/casing/case_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ func TestCaseHelpersWithOptionalReplacer_StrcaseInspiredInitialisms(t *testing.T
6464
r, err := NewReplacer(
6565
Rule{Token: "Api", Replacement: "API"},
6666
Rule{Token: "Http", Replacement: "HTTP"},
67+
Rule{Token: "Https", Replacement: "HTTPS"},
6768
Rule{Token: "Id", Replacement: "ID"},
6869
Rule{Token: "Json", Replacement: "JSON"},
70+
Rule{Token: "Url", Replacement: "URL"},
71+
Rule{Token: "Xss", Replacement: "XSS"},
6972
)
7073
require.NoError(t, err)
7174

@@ -76,6 +79,67 @@ func TestCaseHelpersWithOptionalReplacer_StrcaseInspiredInitialisms(t *testing.T
7679
assert.Equal(t, "httpStatusCode", ToCamelCase("http_status_code", r))
7780
assert.Equal(t, "HTTPStatusCode", ToPascalCase("http_status_code", r))
7881
assert.Equal(t, "http_status_code", ToSnakeCase("HTTPStatusCode", r))
82+
assert.Equal(t, "https", ToCamelCase("https", r))
83+
assert.Equal(t, "HTTPS", ToPascalCase("https", r))
84+
assert.Equal(t, "HTTPS", ToPascalCase("Https", r))
85+
assert.Equal(t, "HTTPS", ToPascalCase("HTTPS", r))
86+
assert.Equal(t, "iHTTPS", ToCamelCase("IHTTPS", r))
87+
assert.Equal(t, "IHTTPS", ToPascalCase("IHTTPS", r))
88+
assert.Equal(t, "IHTTPS", ToPascalCase("IHttps", r))
89+
assert.Equal(t, "ihttps", ToSnakeCase("IHTTPS", r))
90+
assert.Equal(t, "ihttps", ToKebabCase("IHTTPS", r))
91+
assert.Equal(t, "aHTTPClient", ToCamelCase("aHTTPClient", r))
92+
assert.Equal(t, "AHTTPClient", ToPascalCase("aHTTPClient", r))
93+
assert.Equal(t, "iHTTP", ToCamelCase("IHTTP", r))
94+
assert.Equal(t, "iHTTP", ToCamelCase("ihttp", r))
95+
assert.Equal(t, "iHTTP", ToCamelCase("iHttp", r))
96+
assert.Equal(t, "iHTTP2", ToCamelCase("IHTTP2", r))
97+
assert.Equal(t, "iHTTP2", ToCamelCase("ihttp2", r))
98+
assert.Equal(t, "iHTTP2", ToCamelCase("iHttp2", r))
99+
assert.Equal(t, "IHTTP", ToPascalCase("IHTTP", r))
100+
assert.Equal(t, "IHTTP", ToPascalCase("iHTTP", r))
101+
assert.Equal(t, "IHTTP", ToPascalCase("ihttp", r))
102+
assert.Equal(t, "IHTTP", ToPascalCase("iHttp", r))
103+
assert.Equal(t, "IHTTP2", ToPascalCase("IHTTP2", r))
104+
assert.Equal(t, "IHTTP2", ToPascalCase("ihttp2", r))
105+
assert.Equal(t, "IHTTP2", ToPascalCase("iHttp2", r))
106+
assert.Equal(t, "ihttp", ToSnakeCase("IHTTP", r))
107+
assert.Equal(t, "ihttp", ToSnakeCase("iHTTP", r))
108+
assert.Equal(t, "ihttp", ToSnakeCase("iHttp", r))
109+
assert.Equal(t, "ihttp2", ToSnakeCase("IHTTP2", r))
110+
assert.Equal(t, "ihttp2", ToSnakeCase("iHTTP2", r))
111+
assert.Equal(t, "ihttp2", ToSnakeCase("iHttp2", r))
112+
assert.Equal(t, "ihttp", ToKebabCase("IHTTP", r))
113+
assert.Equal(t, "ihttp", ToKebabCase("iHTTP", r))
114+
assert.Equal(t, "ihttp", ToKebabCase("iHttp", r))
115+
assert.Equal(t, "ihttp2", ToKebabCase("IHTTP2", r))
116+
assert.Equal(t, "ihttp2", ToKebabCase("iHTTP2", r))
117+
assert.Equal(t, "ihttp2", ToKebabCase("iHttp2", r))
118+
assert.Equal(t, "URLs", ToPascalCase("urls", r))
119+
assert.Equal(t, "URLs", ToPascalCase("uRLs", r))
120+
assert.Equal(t, "urls", ToCamelCase("urls", r))
121+
assert.Equal(t, "URLs", ToPascalCase("Urls", r))
122+
assert.Equal(t, "xss", ToCamelCase("xss", r))
123+
assert.Equal(t, "XSS", ToPascalCase("xss", r))
124+
assert.Equal(t, "xss", ToSnakeCase("XSS", r))
125+
assert.Equal(t, "xss", ToKebabCase("XSS", r))
126+
assert.Equal(t, "xURLValue", ToCamelCase("xURLValue", r))
127+
assert.Equal(t, "XURLValue", ToPascalCase("xURLValue", r))
128+
assert.Equal(t, "x_url_value", ToSnakeCase("xURLValue", r))
129+
assert.Equal(t, "x-url-value", ToKebabCase("xURLValue", r))
130+
assert.Equal(t, "userURLs", ToCamelCase("userUrls", r))
131+
assert.Equal(t, "userURLs", ToCamelCase("userURLs", r))
132+
assert.Equal(t, "userURLs", ToCamelCase("UserUrls", r))
133+
assert.Equal(t, "userURLs", ToCamelCase("UserURLs", r))
134+
assert.Equal(t, "userURLs", ToCamelCase("user_urls", r))
135+
assert.Equal(t, "UserURLs", ToPascalCase("userUrls", r))
136+
assert.Equal(t, "UserURLs", ToPascalCase("userURLs", r))
137+
assert.Equal(t, "UserURLs", ToPascalCase("UserUrls", r))
138+
assert.Equal(t, "UserURLs", ToPascalCase("UserURLs", r))
139+
assert.Equal(t, "UserURLs", ToPascalCase("user_urls", r))
140+
assert.Equal(t, "user_urls", ToSnakeCase("UserURLs", r))
141+
assert.Equal(t, "user-urls", ToKebabCase("UserURLs", r))
142+
assert.Equal(t, "urls", ToSnakeCase("URLs", r))
79143

80144
assert.Equal(t, "jsonBlob", ToCamelCase("json_blob", r))
81145
assert.Equal(t, "JSONBlob", ToPascalCase("json_blob", r))
@@ -111,3 +175,36 @@ func TestCaseHelpersUseOnlyFirstReplacer(t *testing.T) {
111175

112176
assert.Equal(t, "APIClient", ToPascalCase("api_client", first, second))
113177
}
178+
179+
func TestCaseHelpersCompoundTransformations(t *testing.T) {
180+
r, err := NewReplacer(
181+
Rule{Token: "Http", Replacement: "HTTP"},
182+
Rule{Token: "Https", Replacement: "HTTPS"},
183+
Rule{Token: "Url", Replacement: "URL"},
184+
)
185+
require.NoError(t, err)
186+
187+
assert.Equal(t, "itthps", ToSnakeCase(ToCamelCase("itthps")))
188+
assert.Equal(t, "itthps", ToSnakeCase(ToPascalCase("itthps")))
189+
assert.Equal(t, "i_tthps", ToSnakeCase(ToCamelCase("i_tthps")))
190+
assert.Equal(t, "i_tthps", ToSnakeCase(ToPascalCase("i_tthps")))
191+
assert.Equal(t, "ihttps", ToSnakeCase(ToCamelCase("i_https", r), r))
192+
assert.Equal(t, "ihttps", ToSnakeCase(ToPascalCase("i_https", r), r))
193+
assert.Equal(t, "user_urls", ToSnakeCase(ToCamelCase("user_urls", r), r))
194+
assert.Equal(t, "user_urls", ToSnakeCase(ToPascalCase("user_urls", r), r))
195+
assert.Equal(t, "x-url-value", ToKebabCase(ToCamelCase("x_url_value", r), r))
196+
assert.Equal(t, "x_url_value", ToSnakeCase(ToPascalCase("x_url_value", r), r))
197+
}
198+
199+
func TestCaseHelpersOverlappingCompoundRulesBacktrack(t *testing.T) {
200+
r, err := NewReplacer(
201+
Rule{Token: "A", Replacement: "A"},
202+
Rule{Token: "Ab", Replacement: "AB"},
203+
Rule{Token: "Bc", Replacement: "BC"},
204+
)
205+
require.NoError(t, err)
206+
207+
assert.Equal(t, "ABC", r.Replace("Abc"))
208+
assert.Equal(t, "aBC", ToCamelCase("abc", r))
209+
assert.Equal(t, "ABC", ToPascalCase("abc", r))
210+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package casing
2+
3+
import (
4+
"strings"
5+
"unicode"
6+
7+
"github.com/ARM-software/golang-utils/utils/collection"
8+
)
9+
10+
// This codebase uses a leading `I` to name interfaces, so identifiers such as
11+
// `IHTTP` should be recognised as an interface prefix plus acronym rather than
12+
// as a single undifferentiated all-caps token.
13+
14+
func hasInterfacePrefixAcronymBoundary(runes []rune, index int) bool {
15+
if index != 1 || len(runes) < 3 || runes[0] != 'I' {
16+
return false
17+
}
18+
return unicode.IsUpper(runes[index]) && index+1 < len(runes) && unicode.IsUpper(runes[index+1])
19+
}
20+
21+
func isInterfacePrefixedAcronym(value string) bool {
22+
runes := []rune(value)
23+
if len(runes) < 3 || runes[0] != 'I' {
24+
return false
25+
}
26+
return collection.AllFunc(runes[1:], func(r rune) bool {
27+
return unicode.IsUpper(r) || unicode.IsDigit(r)
28+
})
29+
}
30+
31+
func normaliseInterfacePrefixedAcronym(value string, replacer *Replacer, lowerPrefix bool) (string, bool) {
32+
if replacer == nil || len(value) < 2 {
33+
return "", false
34+
}
35+
runes := []rune(value)
36+
if runes[0] != 'I' && runes[0] != 'i' {
37+
return "", false
38+
}
39+
parts, ok := replacer.splitAdjacentTokenReplacementParts(strings.ToLower(string(runes[1:])))
40+
if !ok || len(parts) == 0 {
41+
return "", false
42+
}
43+
prefix := "I"
44+
if lowerPrefix {
45+
prefix = "i"
46+
}
47+
return prefix + strings.Join(parts, ""), true
48+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package casing
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestInterfaceConventionDetection(t *testing.T) {
11+
assert.True(t, isInterfacePrefixedAcronym("IHTTP"))
12+
assert.True(t, isInterfacePrefixedAcronym("IHTTP2"))
13+
assert.False(t, isInterfacePrefixedAcronym("iHTTP"))
14+
assert.False(t, isInterfacePrefixedAcronym("HTTP"))
15+
assert.False(t, isInterfacePrefixedAcronym("IHttp"))
16+
17+
assert.True(t, hasInterfacePrefixAcronymBoundary([]rune("IHTTP"), 1))
18+
assert.False(t, hasInterfacePrefixAcronymBoundary([]rune("HTTP"), 1))
19+
assert.False(t, hasInterfacePrefixAcronymBoundary([]rune("IHttp"), 1))
20+
}
21+
22+
func TestInterfaceConventionNormalisation(t *testing.T) {
23+
r, err := NewReplacer(
24+
Rule{Token: "Http", Replacement: "HTTP"},
25+
)
26+
require.NoError(t, err)
27+
28+
value, ok := normaliseInterfacePrefixedAcronym("IHTTP", r, false)
29+
require.True(t, ok)
30+
assert.Equal(t, "IHTTP", value)
31+
32+
value, ok = normaliseInterfacePrefixedAcronym("ihttp", r, true)
33+
require.True(t, ok)
34+
assert.Equal(t, "iHTTP", value)
35+
36+
value, ok = normaliseInterfacePrefixedAcronym("iHttp", r, false)
37+
require.True(t, ok)
38+
assert.Equal(t, "IHTTP", value)
39+
40+
_, ok = normaliseInterfacePrefixedAcronym("HTTP", r, false)
41+
assert.False(t, ok)
42+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package casing
2+
3+
import "unicode"
4+
5+
func hasPluralInitialismSuffix(runes []rune, index int) bool {
6+
if index < 0 || index+1 >= len(runes) {
7+
return false
8+
}
9+
return unicode.IsUpper(runes[index]) && unicode.IsLower(runes[index+1]) && index+1 == len(runes)-1 && runes[index+1] == 's'
10+
}
11+
12+
func hasLowerPrefixAcronymBoundary(runes []rune, index int) bool {
13+
if index != 1 || len(runes) < 3 {
14+
return false
15+
}
16+
if !unicode.IsLower(runes[index-1]) || !unicode.IsUpper(runes[index]) || index+1 >= len(runes) || !unicode.IsUpper(runes[index+1]) {
17+
return false
18+
}
19+
for i := index; i < len(runes); i++ {
20+
if unicode.IsUpper(runes[i]) || unicode.IsDigit(runes[i]) {
21+
continue
22+
}
23+
return i == len(runes)-1 && runes[i] == 's'
24+
}
25+
return true
26+
}
27+
28+
func splitPluralInitialismSuffix(remainder string) ([]string, bool) {
29+
if remainder == "s" {
30+
return []string{"s"}, true
31+
}
32+
return nil, false
33+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package casing
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestPluralConventionHelpers(t *testing.T) {
10+
assert.True(t, hasPluralInitialismSuffix([]rune("URLs"), 2))
11+
assert.False(t, hasPluralInitialismSuffix([]rune("URL"), 2))
12+
assert.False(t, hasPluralInitialismSuffix([]rune("urls"), 2))
13+
14+
assert.True(t, hasLowerPrefixAcronymBoundary([]rune("uRLs"), 1))
15+
assert.False(t, hasLowerPrefixAcronymBoundary([]rune("URLs"), 1))
16+
assert.False(t, hasLowerPrefixAcronymBoundary([]rune("userURLs"), 1))
17+
18+
parts, ok := splitPluralInitialismSuffix("s")
19+
assert.True(t, ok)
20+
assert.ElementsMatch(t, []string{"s"}, parts)
21+
22+
parts, ok = splitPluralInitialismSuffix("tail")
23+
assert.False(t, ok)
24+
assert.Nil(t, parts)
25+
}

0 commit comments

Comments
 (0)