Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 15 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,28 @@ 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"`
IgnoreFields []string `json:"ignoreFields"`
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 {
Expand Down
118 changes: 118 additions & 0 deletions processor/aliases_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
2 changes: 2 additions & 0 deletions processor/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 83 additions & 1 deletion processor/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"regexp"
"sort"
"strings"
"unicode"

"github.com/elastic/crd-ref-docs/config"
"github.com/elastic/crd-ref-docs/types"
Expand Down Expand Up @@ -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] != "" {
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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: {}")
Expand Down Expand Up @@ -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()
}
2 changes: 1 addition & 1 deletion templates/asciidoctor/type.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 -}}
|===
Expand Down
2 changes: 1 addition & 1 deletion templates/markdown/type.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ _Appears in:_
{{ end -}}

{{ range $type.Members -}}
| `{{ .Name }}` _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | {{ markdownRenderDefault .Default }} | {{ range .Validation -}} {{ markdownRenderFieldDoc . }} <br />{{ end }} |
| `{{ .Name }}`{{ if .Aliases }}<br/>_(or {{ range $i, $a := .Aliases }}{{ if $i }}, {{ end }}`{{ $a }}`{{ end }})_{{ end }} _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | {{ markdownRenderDefault .Default }} | {{ range .Validation -}} {{ markdownRenderFieldDoc . }} <br />{{ end }} |
{{ end -}}

{{ end -}}
Expand Down
5 changes: 3 additions & 2 deletions types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading