Skip to content

Commit 8c6886f

Browse files
acabarbayeKem-Gov
andauthored
✨ Added a package to change string casing (#908)
This is so that https://go.dev/wiki/CodeReviewComments#initialisms are correctly handled --------- Co-authored-by: Kem Govender <kem.govender@arm.com>
1 parent 9efdbfd commit 8c6886f

9 files changed

Lines changed: 503 additions & 0 deletions

File tree

changes/20260608105000.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: Added a package to change string casing but handling [go initialisms](https://go.dev/wiki/CodeReviewComments#initialisms)

utils/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ require (
4444
github.com/spf13/pflag v1.0.10
4545
github.com/spf13/viper v1.21.0
4646
github.com/stretchr/testify v1.11.1
47+
github.com/sttk/stringcase v0.3.0
4748
github.com/zalando/go-keyring v0.2.8
4849
go.uber.org/atomic v1.11.0
4950
go.uber.org/goleak v1.3.0

utils/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
255255
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
256256
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
257257
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
258+
github.com/sttk/stringcase v0.3.0 h1:O0F67PImqZPBAZKc/hAih2H1coeLGMVwXFBw+7z60uI=
259+
github.com/sttk/stringcase v0.3.0/go.mod h1:o1IEzL6qw8YmFeXvFMxy7cWnL2o6hZ8lggG3acmUxHs=
258260
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
259261
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
260262
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=

utils/strings/casing/case.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package casing
2+
3+
import (
4+
"github.com/sttk/stringcase"
5+
6+
"github.com/ARM-software/golang-utils/utils/collection"
7+
)
8+
9+
// ToCamelCase converts value to camelCase and optionally applies a replacer to the resulting identifier. Only the first replacer is used.
10+
func ToCamelCase(value string, replacers ...*Replacer) string {
11+
result := stringcase.CamelCase(value)
12+
if replacer, ok := collection.First(replacers); ok && replacer != nil {
13+
return replacer.Replace(result)
14+
}
15+
return result
16+
}
17+
18+
// ToPascalCase converts value to PascalCase and optionally applies a replacer to the resulting identifier. Only the first replacer is used.
19+
func ToPascalCase(value string, replacers ...*Replacer) string {
20+
result := stringcase.PascalCase(value)
21+
if replacer, ok := collection.First(replacers); ok && replacer != nil {
22+
return replacer.Replace(result)
23+
}
24+
return result
25+
}
26+
27+
// 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.
28+
func ToSnakeCase(value string, replacers ...*Replacer) string {
29+
result := value
30+
if replacer, ok := collection.First(replacers); ok && replacer != nil {
31+
result = replacer.Replace(stringcase.PascalCase(value))
32+
}
33+
return stringcase.SnakeCase(result)
34+
}
35+
36+
// ToKebabCase converts value to kebab-case and optionally applies a replacer to the identifier before the final case conversion. Only the first replacer is used.
37+
func ToKebabCase(value string, replacers ...*Replacer) string {
38+
result := value
39+
if replacer, ok := collection.First(replacers); ok && replacer != nil {
40+
result = replacer.Replace(stringcase.PascalCase(value))
41+
}
42+
return stringcase.KebabCase(result)
43+
}

utils/strings/casing/case_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 TestCaseHelpersWithOptionalReplacer(t *testing.T) {
11+
r, err := NewReplacer(
12+
Rule{Token: "Api", Replacement: "API"},
13+
Rule{Token: "Id", Replacement: "ID", Exceptions: []string{"identifier"}},
14+
)
15+
require.NoError(t, err)
16+
17+
assert.Equal(t, "apiClient", ToCamelCase("api_client", r))
18+
assert.Equal(t, "APIClient", ToPascalCase("api_client", r))
19+
assert.Equal(t, "api_client", ToSnakeCase("api_client", r))
20+
assert.Equal(t, "api-client", ToKebabCase("api_client", r))
21+
}
22+
23+
func TestCaseHelpersWithoutReplacer(t *testing.T) {
24+
assert.Equal(t, "sourceName", ToCamelCase("source_name"))
25+
assert.Equal(t, "SourceName", ToPascalCase("source_name"))
26+
assert.Equal(t, "source_name", ToSnakeCase("sourceName"))
27+
assert.Equal(t, "source-name", ToKebabCase("sourceName"))
28+
29+
assert.Equal(t, "aiapi", ToSnakeCase("AIAPI"))
30+
assert.Equal(t, "aiapi", ToKebabCase("AIAPI"))
31+
assert.Equal(t, "httpapi_token", ToSnakeCase("HTTPAPIToken"))
32+
assert.Equal(t, "httpapi-token", ToKebabCase("HTTPAPIToken"))
33+
assert.Equal(t, "httpapiToken", ToCamelCase("HTTPAPIToken"))
34+
assert.Equal(t, "HttpapiToken", ToPascalCase("HTTPAPIToken"))
35+
}
36+
37+
func TestCaseHelpersWithoutReplacer_StrcaseInspiredCases(t *testing.T) {
38+
// These cases are adapted from the broader casing corpus used by
39+
// github.com/ettle/strcase to make sure this package behaves consistently on
40+
// common acronym and mixed-token inputs, even though this package does not
41+
// apply Go initialism rules unless explicitly configured through a Replacer.
42+
assert.Equal(t, "Id", ToPascalCase("ID"))
43+
assert.Equal(t, "id", ToCamelCase("ID"))
44+
assert.Equal(t, "id", ToSnakeCase("ID"))
45+
46+
assert.Equal(t, "userId", ToCamelCase("userID"))
47+
assert.Equal(t, "UserId", ToPascalCase("userID"))
48+
assert.Equal(t, "user_id", ToSnakeCase("userID"))
49+
50+
assert.Equal(t, "jsonBlob", ToCamelCase("JSON_blob"))
51+
assert.Equal(t, "JsonBlob", ToPascalCase("JSON_blob"))
52+
assert.Equal(t, "json_blob", ToSnakeCase("JSON_blob"))
53+
54+
assert.Equal(t, "httpStatusCode", ToCamelCase("HTTPStatusCode"))
55+
assert.Equal(t, "HttpStatusCode", ToPascalCase("HTTPStatusCode"))
56+
assert.Equal(t, "http_status_code", ToSnakeCase("HTTPStatusCode"))
57+
58+
assert.Equal(t, "freeBsd", ToCamelCase("FreeBSD"))
59+
assert.Equal(t, "FreeBsd", ToPascalCase("FreeBSD"))
60+
assert.Equal(t, "free_bsd", ToSnakeCase("FreeBSD"))
61+
}
62+
63+
func TestCaseHelpersWithOptionalReplacer_StrcaseInspiredInitialisms(t *testing.T) {
64+
r, err := NewReplacer(
65+
Rule{Token: "Api", Replacement: "API"},
66+
Rule{Token: "Http", Replacement: "HTTP"},
67+
Rule{Token: "Id", Replacement: "ID"},
68+
Rule{Token: "Json", Replacement: "JSON"},
69+
)
70+
require.NoError(t, err)
71+
72+
assert.Equal(t, "userID", ToCamelCase("user_id", r))
73+
assert.Equal(t, "UserID", ToPascalCase("user_id", r))
74+
assert.Equal(t, "user_id", ToSnakeCase("UserID", r))
75+
76+
assert.Equal(t, "httpStatusCode", ToCamelCase("http_status_code", r))
77+
assert.Equal(t, "HTTPStatusCode", ToPascalCase("http_status_code", r))
78+
assert.Equal(t, "http_status_code", ToSnakeCase("HTTPStatusCode", r))
79+
80+
assert.Equal(t, "jsonBlob", ToCamelCase("json_blob", r))
81+
assert.Equal(t, "JSONBlob", ToPascalCase("json_blob", r))
82+
assert.Equal(t, "json_blob", ToSnakeCase("JSONBlob", r))
83+
}
84+
85+
func TestCaseHelpersUseOnlyFirstReplacer(t *testing.T) {
86+
first, err := NewReplacer(Rule{Token: "Api", Replacement: "API"})
87+
require.NoError(t, err)
88+
second, err := NewReplacer(Rule{Token: "Api", Replacement: "XXX"})
89+
require.NoError(t, err)
90+
91+
assert.Equal(t, "APIClient", ToPascalCase("api_client", first, second))
92+
}

utils/strings/casing/doc.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Package casing rewrites identifier-like strings according to declarative
2+
// token replacement rules.
3+
//
4+
// "Identifier-like strings" here means names that are structured as code or
5+
// configuration identifiers rather than arbitrary prose, for example:
6+
// - camelCase names such as `apiClient` or `sourceName`
7+
// - PascalCase names such as `ApiClient` or `SourceName`
8+
// - mixed acronym-containing identifiers such as `HttpAPIClient`
9+
//
10+
// It is intended for cases where callers need to preserve the overall casing
11+
// shape of such identifiers while forcing specific words to use a canonical
12+
// replacement and exempting other whole words through an exception list.
13+
//
14+
// This differs from simpler alternatives such as:
15+
// - `strings.Replacer`, which is useful for literal substring replacement but
16+
// does not understand identifier word boundaries or case reconstruction
17+
// - regexp-based rewriting, which can express some token patterns but becomes
18+
// awkward when replacement depends on identifier-style word splitting,
19+
// acronym policies, and explicit exception lists
20+
//
21+
// The package therefore combines identifier splitting with rule-based word
22+
// replacement, rather than treating the input as an arbitrary free-form string.
23+
//
24+
// Unlike libraries that automatically apply a built-in Go initialism list, this
25+
// package only applies that behaviour when explicitly requested. Callers can opt
26+
// into it by creating a replacer from [InitialismRules] or by reusing the
27+
// ready-made [InitialismReplacer]. This keeps the package predictable for
28+
// domain-specific casing policies while still providing built-in optional Go
29+
// initialism support when wanted.
30+
//
31+
// Typical uses include:
32+
// - normalising known acronyms in generated identifiers
33+
// - preserving exception words that should not be rewritten
34+
// - applying a shared replacement policy across tools or generators
35+
//
36+
// Related references:
37+
// - Go initialisms guidance in effective naming:
38+
// https://go.dev/wiki/CodeReviewComments#initialisms
39+
// - `ettle/strcase`, which includes a broad casing test corpus and built-in
40+
// Go-initialism-aware conversions:
41+
// https://github.com/ettle/strcase
42+
//
43+
// The case-conversion helpers in this package accept `...*Replacer` so callers
44+
// can use one common helper shape whether they want replacement or not. Only
45+
// the first replacer is used; passing more than one replacer is not supported.
46+
package casing
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package casing
2+
3+
// InitialismRules defines replacement rules for the common Go initialisms.
4+
//
5+
// This variable can be passed directly to [NewReplacer] to opt into built-in
6+
// Go-style initialism normalisation, for example:
7+
//
8+
// r, err := NewReplacer(InitialismRules...)
9+
//
10+
// The list is based on the standard Go initialism guidance:
11+
// https://go.dev/wiki/CodeReviewComments#initialisms
12+
var InitialismRules = []Rule{
13+
{Token: "Acl", Replacement: "ACL"},
14+
{Token: "Api", Replacement: "API"},
15+
{Token: "Ascii", Replacement: "ASCII"},
16+
{Token: "Cpu", Replacement: "CPU"},
17+
{Token: "Css", Replacement: "CSS"},
18+
{Token: "Dns", Replacement: "DNS"},
19+
{Token: "Eof", Replacement: "EOF"},
20+
{Token: "Guid", Replacement: "GUID"},
21+
{Token: "Html", Replacement: "HTML"},
22+
{Token: "Http", Replacement: "HTTP"},
23+
{Token: "Https", Replacement: "HTTPS"},
24+
{Token: "Id", Replacement: "ID"},
25+
{Token: "Ip", Replacement: "IP"},
26+
{Token: "Json", Replacement: "JSON"},
27+
{Token: "Lhs", Replacement: "LHS"},
28+
{Token: "Qps", Replacement: "QPS"},
29+
{Token: "Ram", Replacement: "RAM"},
30+
{Token: "Rhs", Replacement: "RHS"},
31+
{Token: "Rpc", Replacement: "RPC"},
32+
{Token: "Sla", Replacement: "SLA"},
33+
{Token: "Smtp", Replacement: "SMTP"},
34+
{Token: "Sql", Replacement: "SQL"},
35+
{Token: "Ssh", Replacement: "SSH"},
36+
{Token: "Tcp", Replacement: "TCP"},
37+
{Token: "Tls", Replacement: "TLS"},
38+
{Token: "Ttl", Replacement: "TTL"},
39+
{Token: "Udp", Replacement: "UDP"},
40+
{Token: "Ui", Replacement: "UI"},
41+
{Token: "Uid", Replacement: "UID"},
42+
{Token: "Uuid", Replacement: "UUID"},
43+
{Token: "Uri", Replacement: "URI"},
44+
{Token: "Url", Replacement: "URL"},
45+
{Token: "Utf8", Replacement: "UTF8"},
46+
{Token: "Vm", Replacement: "VM"},
47+
{Token: "Xml", Replacement: "XML"},
48+
{Token: "Xmpp", Replacement: "XMPP"},
49+
{Token: "Xsrf", Replacement: "XSRF"},
50+
{Token: "Xss", Replacement: "XSS"},
51+
}
52+
53+
// InitialismReplacer is a ready-to-use replacer built from [InitialismRules].
54+
//
55+
// This variable provides built-in optional Go initialism support without
56+
// requiring callers to construct a replacer explicitly.
57+
var InitialismReplacer = mustNewReplacer(InitialismRules...)
58+
59+
func mustNewReplacer(rules ...Rule) *Replacer {
60+
replacer, err := NewReplacer(rules...)
61+
if err != nil {
62+
panic(err)
63+
}
64+
return replacer
65+
}

0 commit comments

Comments
 (0)