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