diff --git a/README.md b/README.md
index 789f795..0106f26 100644
--- a/README.md
+++ b/README.md
@@ -122,3 +122,20 @@ type Embedded1 struct {
```
Then update the templates to render the custom markers. You can find an example [here](./test/templates/markdown/type.tpl).
+
+#### Field Aliases
+
+For fields carrying the `encoding/json/v2` `case:ignore` tag option, the documentation can
+list alternative names alongside the canonical one. Set `processor.caseIgnoreAliases` to the
+conventions to derive (`camelCase` and/or `snake_case`); when empty (the default) no aliases
+are shown.
+
+```yaml
+processor:
+ caseIgnoreAliases:
+ - camelCase
+ - snake_case
+```
+
+For a field tagged `json:"groupWait,case:ignore"`, the documentation displays `groupWait`
+together with the derived `group_wait` alias.
diff --git a/config/config.go b/config/config.go
index b1c792e..5f8fefe 100644
--- a/config/config.go
+++ b/config/config.go
@@ -29,6 +29,17 @@ type Config struct {
Flags `json:"-"`
}
+// NamingConvention identifies a field-naming style to derive as an alias
+// when a struct field carries the json `case:ignore` tag option.
+type NamingConvention string
+
+const (
+ // NamingConventionCamelCase derives the lowerCamelCase form (e.g. "groupWait" for "group_wait").
+ NamingConventionCamelCase NamingConvention = "camelCase"
+ // NamingConventionSnakeCase derives the snake_case form (e.g. "group_wait" for "groupWait").
+ NamingConventionSnakeCase NamingConvention = "snake_case"
+)
+
type ProcessorConfig struct {
MaxDepth int `json:"maxDepth"`
IgnoreTypes []string `json:"ignoreTypes"`
@@ -36,6 +47,10 @@ type ProcessorConfig struct {
IgnoreGroupVersions []string `json:"ignoreGroupVersions"`
UseRawDocstring bool `json:"useRawDocstring"`
CustomMarkers []Marker `json:"customMarkers"`
+ // CaseIgnoreAliases lists the naming conventions to derive and display as
+ // alternative field names whenever a struct field carries the json `case:ignore`
+ // tag option. When empty, no aliases are shown for such fields.
+ CaseIgnoreAliases []NamingConvention `json:"caseIgnoreAliases"`
}
type Marker struct {
diff --git a/processor/aliases_test.go b/processor/aliases_test.go
new file mode 100644
index 0000000..d03dfc6
--- /dev/null
+++ b/processor/aliases_test.go
@@ -0,0 +1,118 @@
+package processor
+
+import (
+ "testing"
+
+ "github.com/elastic/crd-ref-docs/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHasCaseIgnore(t *testing.T) {
+ testCases := []struct {
+ name string
+ options []string
+ want bool
+ }{
+ {name: "empty", options: nil, want: false},
+ {name: "name only", options: []string{"groupWait"}, want: false},
+ {name: "case ignore", options: []string{"groupWait", "case:ignore"}, want: true},
+ {name: "case ignore with spaces", options: []string{"groupWait", " case:ignore "}, want: true},
+ {name: "case ignore among options", options: []string{"groupWait", "omitempty", "case:ignore"}, want: true},
+ {name: "case strict", options: []string{"groupWait", "case:strict"}, want: false},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.want, hasCaseIgnore(tc.options))
+ })
+ }
+}
+
+func TestToSnakeCase(t *testing.T) {
+ testCases := []struct {
+ in string
+ want string
+ }{
+ {in: "groupWait", want: "group_wait"},
+ {in: "apiURL", want: "api_url"},
+ {in: "URLApi", want: "url_api"},
+ {in: "group_wait", want: "group_wait"},
+ {in: "HTTPSProxy", want: "https_proxy"},
+ {in: "GroupWait", want: "group_wait"},
+ {in: "v1Beta", want: "v1_beta"},
+ {in: "enabled", want: "enabled"},
+ {in: "x", want: "x"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.in, func(t *testing.T) {
+ require.Equal(t, tc.want, toSnakeCase(tc.in))
+ })
+ }
+}
+
+func TestToCamelCase(t *testing.T) {
+ testCases := []struct {
+ in string
+ want string
+ }{
+ {in: "group_wait", want: "groupWait"},
+ {in: "api_url", want: "apiUrl"},
+ {in: "groupWait", want: "groupWait"},
+ {in: "enabled", want: "enabled"},
+ {in: "x", want: "x"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.in, func(t *testing.T) {
+ require.Equal(t, tc.want, toCamelCase(tc.in))
+ })
+ }
+}
+
+func TestDeriveAliases(t *testing.T) {
+ testCases := []struct {
+ name string
+ conventions []config.NamingConvention
+ canonicalName string
+ want []string
+ }{
+ {
+ name: "no conventions",
+ conventions: nil,
+ canonicalName: "groupWait",
+ want: nil,
+ },
+ {
+ name: "snake alias for camel field",
+ conventions: []config.NamingConvention{config.NamingConventionSnakeCase},
+ canonicalName: "groupWait",
+ want: []string{"group_wait"},
+ },
+ {
+ name: "camel alias for snake field",
+ conventions: []config.NamingConvention{config.NamingConventionCamelCase},
+ canonicalName: "group_wait",
+ want: []string{"groupWait"},
+ },
+ {
+ name: "identical derivation omitted",
+ conventions: []config.NamingConvention{config.NamingConventionCamelCase, config.NamingConventionSnakeCase},
+ canonicalName: "groupWait",
+ want: []string{"group_wait"},
+ },
+ {
+ name: "single word yields no aliases",
+ conventions: []config.NamingConvention{config.NamingConventionCamelCase, config.NamingConventionSnakeCase},
+ canonicalName: "enabled",
+ want: nil,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ p := &processor{compiledConfig: &compiledConfig{caseIgnoreAliases: tc.conventions}}
+ require.Equal(t, tc.want, p.deriveAliases(tc.canonicalName))
+ })
+ }
+}
diff --git a/processor/config.go b/processor/config.go
index 37cd640..b59f64d 100644
--- a/processor/config.go
+++ b/processor/config.go
@@ -34,6 +34,7 @@ func compileConfig(conf *config.Config) (cc *compiledConfig, err error) {
ignoreGroupVersions: make([]*regexp.Regexp, len(conf.Processor.IgnoreGroupVersions)),
useRawDocstring: conf.Processor.UseRawDocstring,
markers: conf.Processor.CustomMarkers,
+ caseIgnoreAliases: conf.Processor.CaseIgnoreAliases,
}
for i, t := range conf.Processor.IgnoreTypes {
@@ -63,6 +64,7 @@ type compiledConfig struct {
ignoreGroupVersions []*regexp.Regexp
useRawDocstring bool
markers []config.Marker
+ caseIgnoreAliases []config.NamingConvention
}
func (cc *compiledConfig) shouldIgnoreGroupVersion(gv string) bool {
diff --git a/processor/processor.go b/processor/processor.go
index 3c2d056..0b5d1f6 100644
--- a/processor/processor.go
+++ b/processor/processor.go
@@ -25,6 +25,7 @@ import (
"regexp"
"sort"
"strings"
+ "unicode"
"github.com/elastic/crd-ref-docs/config"
"github.com/elastic/crd-ref-docs/types"
@@ -404,6 +405,7 @@ func (p *processor) processStructFields(parentType *types.Type, pkg *loader.Pack
Embedded: f.Name == "",
}
+ var caseIgnore bool
if tagVal, ok := f.Tag.Lookup("json"); ok {
args := strings.Split(tagVal, ",")
if len(args) > 0 && args[0] != "" {
@@ -412,6 +414,7 @@ func (p *processor) processStructFields(parentType *types.Type, pkg *loader.Pack
if len(args) > 1 && args[1] == "inline" {
fieldDef.Inlined = true
}
+ caseIgnore = hasCaseIgnore(args)
}
t := pkg.TypesInfo.TypeOf(f.RawField.Type)
@@ -433,6 +436,12 @@ func (p *processor) processStructFields(parentType *types.Type, pkg *loader.Pack
fieldDef.Name = fieldDef.Type.Name
}
+ // Derive aliases once the field name has been fully resolved, so they
+ // are based on the displayed name rather than an intermediate value.
+ if caseIgnore {
+ fieldDef.Aliases = p.deriveAliases(fieldDef.Name)
+ }
+
if p.shouldIgnoreField(parentTypeKey, fieldDef.Name) {
zap.S().Debugw("Skipping excluded field", "type", parentType.String(), "field", fieldDef.Name)
continue
@@ -569,7 +578,7 @@ func parseMarkers(markers markers.MarkerValues) (string, []string) {
defaultValue = fmt.Sprintf("%v", v.Value)
}
- // Handle standalone +required and +k8s:required marker
+ // Handle standalone +required and +k8s:required marker
// This is equivalent to +kubebuilder:validation:Required
if name == "required" || name == "k8s:required" {
validation = append(validation, "Required: {}")
@@ -632,3 +641,76 @@ func lookupConstantValuesForAliasedType(pkg *loader.Package, aliasTypeName strin
}
return values
}
+
+// hasCaseIgnore reports whether the comma-separated json struct tag options
+// contain the "case:ignore" option.
+func hasCaseIgnore(options []string) bool {
+ for _, part := range options {
+ if strings.TrimSpace(part) == "case:ignore" {
+ return true
+ }
+ }
+ return false
+}
+
+// deriveAliases returns alternative names for canonicalName based on the
+// configured caseIgnoreAliases conventions. Names identical to canonicalName
+// are omitted.
+func (p *processor) deriveAliases(canonicalName string) []string {
+ var aliases []string
+ for _, conv := range p.caseIgnoreAliases {
+ var derived string
+ switch conv {
+ case config.NamingConventionCamelCase:
+ derived = toCamelCase(canonicalName)
+ case config.NamingConventionSnakeCase:
+ derived = toSnakeCase(canonicalName)
+ }
+ if derived != "" && derived != canonicalName {
+ aliases = append(aliases, derived)
+ }
+ }
+ return aliases
+}
+
+// toSnakeCase converts a camelCase or PascalCase string to snake_case.
+// e.g. "groupWait" -> "group_wait", "apiURL" -> "api_url", "v1Beta" -> "v1_beta"
+func toSnakeCase(s string) string {
+ runes := []rune(s)
+ var b strings.Builder
+ for i, r := range runes {
+ if i == 0 {
+ b.WriteRune(unicode.ToLower(r))
+ continue
+ }
+ if unicode.IsUpper(r) {
+ prev := runes[i-1]
+ if unicode.IsLower(prev) || unicode.IsDigit(prev) || (unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1])) {
+ b.WriteByte('_')
+ }
+ b.WriteRune(unicode.ToLower(r))
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// toCamelCase converts a snake_case string to lowerCamelCase.
+// e.g. "group_wait" -> "groupWait"
+func toCamelCase(s string) string {
+ parts := strings.Split(s, "_")
+ if len(parts) == 1 {
+ return s
+ }
+ var b strings.Builder
+ for i, p := range parts {
+ if i == 0 || len(p) == 0 {
+ b.WriteString(p)
+ } else {
+ b.WriteRune(unicode.ToUpper(rune(p[0])))
+ b.WriteString(p[1:])
+ }
+ }
+ return b.String()
+}
diff --git a/templates/asciidoctor/type.tpl b/templates/asciidoctor/type.tpl
index b28b51a..2f3ffcd 100644
--- a/templates/asciidoctor/type.tpl
+++ b/templates/asciidoctor/type.tpl
@@ -35,7 +35,7 @@
{{ end -}}
{{ range $type.Members -}}
-| *`{{ .Name }}`* __{{ asciidocRenderType .Type }}__ | {{ template "type_members" . }} | {{ .Default }} | {{ range .Validation -}} {{ asciidocRenderValidation . }} +
+| *`{{ .Name }}`*{{ if .Aliases }} _(or {{ range $i, $a := .Aliases }}{{ if $i }}, {{ end }}`{{ $a }}`{{ end }})_{{ end }} __{{ asciidocRenderType .Type }}__ | {{ template "type_members" . }} | {{ .Default }} | {{ range .Validation -}} {{ asciidocRenderValidation . }} +
{{ end }}
{{ end -}}
|===
diff --git a/templates/markdown/type.tpl b/templates/markdown/type.tpl
index 7d89d04..00f08b3 100644
--- a/templates/markdown/type.tpl
+++ b/templates/markdown/type.tpl
@@ -31,7 +31,7 @@ _Appears in:_
{{ end -}}
{{ range $type.Members -}}
-| `{{ .Name }}` _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | {{ markdownRenderDefault .Default }} | {{ range .Validation -}} {{ markdownRenderFieldDoc . }}
{{ end }} |
+| `{{ .Name }}`{{ if .Aliases }}
_(or {{ range $i, $a := .Aliases }}{{ if $i }}, {{ end }}`{{ $a }}`{{ end }})_{{ end }} _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | {{ markdownRenderDefault .Default }} | {{ range .Validation -}} {{ markdownRenderFieldDoc . }}
{{ end }} |
{{ end -}}
{{ end -}}
diff --git a/types/types.go b/types/types.go
index 9c2f9c3..3388dad 100644
--- a/types/types.go
+++ b/types/types.go
@@ -284,8 +284,9 @@ func (t *Type) propagateMarkers() {
// Field describes a field in a struct.
type Field struct {
Name string
- Embedded bool // Embedded struct in Go typing
- Inlined bool // Inlined struct in serialization
+ Aliases []string // alternative names derived from the json "case:ignore" tag option
+ Embedded bool // Embedded struct in Go typing
+ Inlined bool // Inlined struct in serialization
Doc string
Default string
Validation []string