From c15aa83cc7cfed119643ac3b2eb87cc03c687864 Mon Sep 17 00:00:00 2001 From: "Kirill Che." Date: Tue, 6 Feb 2024 16:20:21 +0400 Subject: [PATCH] fix: parse anonymous structure fields If the field is `envPrefix`-ed anonymous structure, then inspector handles this structure with random-generated type name and use field doc and comments as struct attributes. Fix: #5 --- _examples/complex-nostyle.html | 6 ++ _examples/complex.go | 8 +++ _examples/complex.html | 6 ++ _examples/complex.md | 3 + _examples/complex.txt | 3 + _examples/x_complex.md | 3 + inspector.go | 106 +++++++++++++++++++++++++-------- inspector_test.go | 62 +++++-------------- testdata/anonymous.go | 10 ++++ utils.go | 11 ++++ 10 files changed, 147 insertions(+), 71 deletions(-) create mode 100644 testdata/anonymous.go diff --git a/_examples/complex-nostyle.html b/_examples/complex-nostyle.html index 71c8ca7..59dd4b5 100644 --- a/_examples/complex-nostyle.html +++ b/_examples/complex-nostyle.html @@ -23,6 +23,12 @@

ComplexConfig

  • HOSTS (separated by ":", required) - Hosts is a list of hosts.
  • WORDS (comma-separated, from-file, default: one,two,three) - Words is just a list of words.
  • COMMENT (required, default: This is a comment.) - Just a comment.
  • +
  • Anon is an anonymous structure. + +
  • NextConfig

    diff --git a/_examples/complex.go b/_examples/complex.go index eec1ae1..7ec8e05 100644 --- a/_examples/complex.go +++ b/_examples/complex.go @@ -27,6 +27,14 @@ type ComplexConfig struct { Words []string `env:"WORDS,file" envDefault:"one,two,three"` Comment string `env:"COMMENT,required" envDefault:"This is a comment."` // Just a comment. + + // Anon is an anonymous structure. + Anon struct { + // User is a user name. + User string `env:"USER,required"` + // Pass is a password. + Pass string `env:"PASS,required"` + } `envPrefix:"ANON_"` } type NextConfig struct { // NextConfig is a configuration structure. diff --git a/_examples/complex.html b/_examples/complex.html index 16a25bb..a105bd3 100644 --- a/_examples/complex.html +++ b/_examples/complex.html @@ -89,6 +89,12 @@

    ComplexConfig

  • HOSTS (separated by ":", required) - Hosts is a list of hosts.
  • WORDS (comma-separated, from-file, default: one,two,three) - Words is just a list of words.
  • COMMENT (required, default: This is a comment.) - Just a comment.
  • +
  • Anon is an anonymous structure. + +
  • NextConfig

    diff --git a/_examples/complex.md b/_examples/complex.md index 9fcbb8a..e6d895a 100644 --- a/_examples/complex.md +++ b/_examples/complex.md @@ -14,6 +14,9 @@ It is trying to cover all the possible cases. - `HOSTS` (separated by `:`, **required**) - Hosts is a list of hosts. - `WORDS` (comma-separated, from-file, default: `one,two,three`) - Words is just a list of words. - `COMMENT` (**required**, default: `This is a comment.`) - Just a comment. + - Anon is an anonymous structure. + - `ANON_USER` (**required**) - User is a user name. + - `ANON_PASS` (**required**) - Pass is a password. ## NextConfig diff --git a/_examples/complex.txt b/_examples/complex.txt index 1891eb5..4cc5afa 100644 --- a/_examples/complex.txt +++ b/_examples/complex.txt @@ -14,6 +14,9 @@ It is trying to cover all the possible cases. * `HOSTS` (separated by `:`, required) - Hosts is a list of hosts. * `WORDS` (comma-separated, from-file, default: `one,two,three`) - Words is just a list of words. * `COMMENT` (required, default: `This is a comment.`) - Just a comment. + * Anon is an anonymous structure. + * `ANON_USER` (required) - User is a user name. + * `ANON_PASS` (required) - Pass is a password. ## NextConfig diff --git a/_examples/x_complex.md b/_examples/x_complex.md index 39fd51e..84152b5 100644 --- a/_examples/x_complex.md +++ b/_examples/x_complex.md @@ -14,6 +14,9 @@ It is trying to cover all the possible cases. - `X_HOSTS` (separated by `:`, **required**) - Hosts is a list of hosts. - `X_WORDS` (comma-separated, from-file, default: `one,two,three`) - Words is just a list of words. - `X_COMMENT` (**required**, default: `This is a comment.`) - Just a comment. + - `X_` - Anon is an anonymous structure. + - `X_ANON_USER` (**required**) - User is a user name. + - `X_ANON_PASS` (**required**) - Pass is a password. ## NextConfig diff --git a/inspector.go b/inspector.go index eab83e3..394c458 100644 --- a/inspector.go +++ b/inspector.go @@ -32,22 +32,35 @@ type envStruct struct { fields []envField } +type anonymousStruct struct { + name string // generated name + doc *ast.CommentGroup + comments *ast.CommentGroup +} + type inspector struct { typeName string // type name to generate documentation for, could be empty all bool // generate documentation for all types in the file execLine int // line number of the go:generate directive useFieldNames bool // use field names if tag is not specified - fileSet *token.FileSet - lines []int - pendingType bool - items []*envStruct - doc *doc.Package - err error + fileSet *token.FileSet + lines []int + pendingType bool + items []*envStruct + anonymousStructs map[[2]token.Pos]anonymousStruct // map of anonymous structs by token position + doc *doc.Package + err error } func newInspector(typeName string, all bool, execLine int, useFieldNames bool) *inspector { - return &inspector{typeName: typeName, all: all, execLine: execLine, useFieldNames: useFieldNames} + return &inspector{ + typeName: typeName, + all: all, + execLine: execLine, + useFieldNames: useFieldNames, + anonymousStructs: make(map[[2]token.Pos]anonymousStruct), + } } func (i *inspector) inspectFile(fileName string) ([]*EnvScope, error) { @@ -126,35 +139,47 @@ func (i *inspector) Visit(n ast.Node) ast.Visitor { i.pendingType = true return i case *ast.TypeSpec: + debug("type spec: %s (%T) (%d-%d)", t.Name.Name, t.Type, t.Pos(), t.End()) if i.typeName == "" && i.pendingType { i.typeName = t.Name.Name } if st, ok := t.Type.(*ast.StructType); ok { - str := i.getStruct(t) - debug("parsing struct %s", str.name) - for _, field := range st.Fields.List { - items := i.parseField(field) - for i, item := range items { - if item.kind == envFieldKindPlain { - debug("parsed field[%d] %s", i, item.name) - } else { - debug("parsed field[%d] %s (struct ref: %s, prefix: %s)", i, item.name, item.typeRef, item.envPrefix) - } - } - if len(items) == 0 { - continue - } - str.fields = append(str.fields, items...) - } + i.processStruct(t, st) } // reset pending type flag event if this type // is not processable (e.g. interface type). i.pendingType = false + case *ast.StructType: + posRange := [2]token.Pos{t.Pos(), t.End()} + as, ok := i.anonymousStructs[posRange] + if !ok { + return i + } + typeSpec := &ast.TypeSpec{ + Name: &ast.Ident{Name: as.name}, + Doc: as.doc, + Comment: as.comments, + } + i.processStruct(typeSpec, t) + + debug("struct type: %T (%d-%d)", t, t.Pos(), t.End()) } return i } +func (i *inspector) processStruct(t *ast.TypeSpec, st *ast.StructType) { + str := i.getStruct(t) + debug("parsing struct %s", str.name) + for _, field := range st.Fields.List { + items := i.parseField(field) + if len(items) == 0 { + continue + } + str.fields = append(str.fields, items...) + } +} + func (i *inspector) parseType(t *ast.TypeSpec) *envStruct { typeName := t.Name.Name docStr := strings.TrimSpace(t.Doc.Text()) @@ -205,8 +230,28 @@ func (i *inspector) parseField(f *ast.Field) (out []envField) { var item envField item.envPrefix = envPrefix[0] item.kind = envFieldKindStruct - fieldType := f.Type.(*ast.Ident) - item.typeRef = fieldType.Name + switch fieldType := f.Type.(type) { + case *ast.Ident: + item.typeRef = fieldType.Name + case *ast.StructType: + nameGen := fastRandString(16) + i.getStruct(&ast.TypeSpec{ + Name: &ast.Ident{Name: nameGen}, + Type: fieldType, + Doc: &ast.CommentGroup{List: f.Doc.List}, + }) + item.typeRef = nameGen + posRange := [2]token.Pos{fieldType.Pos(), fieldType.End()} + i.anonymousStructs[posRange] = anonymousStruct{ + name: nameGen, + doc: f.Doc, + comments: f.Comment, + } + debug("anonymous struct found: %s (%d-%d)", nameGen, f.Type.Pos(), f.Type.End()) + + default: + panic(fmt.Sprintf("unsupported field type: %T", f.Type)) + } fieldNames := make([]string, len(f.Names)) for i, name := range f.Names { fieldNames[i] = name.Name @@ -288,6 +333,17 @@ func (i *inspector) buildScopes() ([]*EnvScope, error) { debug("skip %q", s.name) continue } + var isAnonymous bool + for _, f := range i.anonymousStructs { + if f.name == s.name { + isAnonymous = true + break + } + } + if isAnonymous { + debug("skip anonymous struct %q", s.name) + continue + } debug("process %q", s.name) scope := &EnvScope{ diff --git a/inspector_test.go b/inspector_test.go index 823d37a..157bfbf 100644 --- a/inspector_test.go +++ b/inspector_test.go @@ -277,52 +277,6 @@ func TestInspector(t *testing.T) { }, }, { - /* - type Settings struct { - // Database is the database settings - Database Database `envPrefix:"DB_"` - - // Server is the server settings - Server ServerConfig `envPrefix:"SERVER_"` - - // Debug is the debug flag - Debug bool `env:"DEBUG"` - } - - // Database is the database settings. - type Database struct { - // Port is the port to connect to - Port Int `env:"PORT,required"` - // Host is the host to connect to - Host string `env:"HOST,nonempty" envDefault:"localhost"` - // User is the user to connect as - User string `env:"USER"` - // Password is the password to use - Password string `env:"PASSWORD"` - // DisableTLS is the flag to disable TLS - DisableTLS bool `env:"DISABLE_TLS"` - } - - // ServerConfig is the server settings. - type ServerConfig struct { - // Port is the port to listen on - Port Int `env:"PORT,required"` - - // Host is the host to listen on - Host string `env:"HOST,nonempty" envDefault:"localhost"` - - // Timeout is the timeout settings - Timeout TimeoutConfig `envPrefix:"TIMEOUT_"` - } - - // TimeoutConfig is the timeout settings. - type TimeoutConfig struct { - // Read is the read timeout - Read Int `env:"READ" envDefault:"30"` - // Write is the write timeout - Write Int `env:"WRITE" envDefault:"30"` - } - */ name: "envprefix.go", typeName: "Settings", expect: []EnvDocItem{ @@ -392,6 +346,22 @@ func TestInspector(t *testing.T) { }, }, }, + { + name: "anonymous.go", + typeName: "Config", + expect: []EnvDocItem{ + { + Doc: "Repo is the configuration for the repository.", + Children: []EnvDocItem{ + { + Name: "REPO_CONN", + Doc: "Conn is the connection string for the repository.", + Opts: EnvVarOptions{Required: true, NonEmpty: true}, + }, + }, + }, + }, + }, } { scopes := c.expectScopes if scopes == nil { diff --git a/testdata/anonymous.go b/testdata/anonymous.go new file mode 100644 index 0000000..1c170ac --- /dev/null +++ b/testdata/anonymous.go @@ -0,0 +1,10 @@ +package main + +// Config is the configuration for the application. +type Config struct { + // Repo is the configuration for the repository. + Repo struct { + // Conn is the connection string for the repository. + Conn string `env:"CONN,notEmpty"` + } `envPrefix:"REPO_"` +} diff --git a/utils.go b/utils.go index d28df46..8572f84 100644 --- a/utils.go +++ b/utils.go @@ -2,6 +2,7 @@ package main import ( "io" + "math/rand" "strings" "unicode" ) @@ -29,3 +30,13 @@ func camelToSnake(s string) string { return result.String() } + +func fastRandString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + seed := rand.Intn(len(letters)*len(letters)) + 1 + b := make([]byte, n) + for i := range b { + b[i] = letters[(seed+i)%len(letters)] + } + return string(b) +}