diff --git a/README.md b/README.md index 03b016e..2da25a5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ type Config struct { * `-all` - Generate documentation for all types in the file. * `-env-prefix` - Environment variable prefix. * `-no-styles` - Disable built-int CSS styles for HTML format. + * `-field-names` - Use field names instead of struct tags for variable names + if tags are not set. ## Example diff --git a/_examples/complex-fields.md b/_examples/complex-fields.md new file mode 100644 index 0000000..3ba3779 --- /dev/null +++ b/_examples/complex-fields.md @@ -0,0 +1,12 @@ +# Environment Variables + +## FieldNames + +FieldNames uses field names as env names. + + - `FOO` - Foo is a single field. + - `BAR` - Bar and Baz are two fields. + - `BAZ` - Bar and Baz are two fields. + - `QUUX` - Quux is a field with a tag. + - `FOO_BAR` (default: `quuux`) - FooBar is a field with a default value. + - `REQUIRED` (**required**) - Required is a required field. diff --git a/_examples/complex-nostyle.html b/_examples/complex-nostyle.html index 96e439a..f8ab3cf 100644 --- a/_examples/complex-nostyle.html +++ b/_examples/complex-nostyle.html @@ -31,6 +31,13 @@

NextConfig

+ +

FieldNames

+

FieldNames uses field names as env names.

+ diff --git a/_examples/complex.go b/_examples/complex.go index fda57e9..eec1ae1 100644 --- a/_examples/complex.go +++ b/_examples/complex.go @@ -33,3 +33,19 @@ type NextConfig struct { // NextConfig is a configuration structure. // Mount is a mount point. Mount string `env:"MOUNT,required"` } + +// FieldNames uses field names as env names. +// +//go:generate go run ../ -output complex-fields.md -field-names +type FieldNames struct { + // Foo is a single field. + Foo string + // Bar and Baz are two fields. + Bar, Baz string + // Quux is a field with a tag. + Quux string `env:"QUUX"` + // FooBar is a field with a default value. + FooBar string `envDefault:"quuux"` + // Required is a required field. + Required string `env:",required"` +} diff --git a/_examples/complex.html b/_examples/complex.html index 4632d14..8d1268d 100644 --- a/_examples/complex.html +++ b/_examples/complex.html @@ -97,6 +97,13 @@

NextConfig

+ +

FieldNames

+

FieldNames uses field names as env names.

+ diff --git a/_examples/complex.md b/_examples/complex.md index 64bed6d..9fcbb8a 100644 --- a/_examples/complex.md +++ b/_examples/complex.md @@ -18,3 +18,9 @@ It is trying to cover all the possible cases. ## NextConfig - `MOUNT` (**required**) - Mount is a mount point. + +## FieldNames + +FieldNames uses field names as env names. + + - `QUUX` - Quux is a field with a tag. diff --git a/_examples/complex.txt b/_examples/complex.txt index 7d322f7..1891eb5 100644 --- a/_examples/complex.txt +++ b/_examples/complex.txt @@ -18,3 +18,9 @@ It is trying to cover all the possible cases. ## NextConfig * `MOUNT` (required) - Mount is a mount point. + +## FieldNames + +FieldNames uses field names as env names. + + * `QUUX` - Quux is a field with a tag. diff --git a/_examples/x_complex.md b/_examples/x_complex.md index 8abc90d..39fd51e 100644 --- a/_examples/x_complex.md +++ b/_examples/x_complex.md @@ -18,3 +18,9 @@ It is trying to cover all the possible cases. ## NextConfig - `X_MOUNT` (**required**) - Mount is a mount point. + +## FieldNames + +FieldNames uses field names as env names. + + - `X_QUUX` - Quux is a field with a tag. diff --git a/doc.go b/doc.go index 760a417..32eecbf 100644 --- a/doc.go +++ b/doc.go @@ -37,5 +37,7 @@ Options: - `-all` - Generate documentation for all types in the file. - `-env-prefix` - Environment variable prefix. - `-no-styles` - Disable built-int CSS styles for HTML format. + - `-field-names` - Use field names instead of struct tags for variable names + if tags are not set. */ package main diff --git a/generator.go b/generator.go index e17def8..af11b96 100644 --- a/generator.go +++ b/generator.go @@ -13,6 +13,7 @@ type generator struct { tmpl template prefix string noStyles bool + fieldNames bool } type generatorOption func(*generator) error @@ -65,6 +66,13 @@ func withNoStyles() generatorOption { } } +func withFieldNames() generatorOption { + return func(g *generator) error { + g.fieldNames = true + return nil + } +} + func newGenerator(fileName string, execLine int, opts ...generatorOption) (*generator, error) { g := &generator{fileName: fileName, execLine: execLine} for _, opt := range opts { @@ -79,7 +87,7 @@ func newGenerator(fileName string, execLine int, opts ...generatorOption) (*gene } func (g *generator) generate(out io.Writer) error { - insp := newInspector(g.targetType, g.all, g.execLine) + insp := newInspector(g.targetType, g.all, g.execLine, g.fieldNames) data, err := insp.inspectFile(g.fileName) if err != nil { return fmt.Errorf("inspect file: %w", err) diff --git a/generator_test.go b/generator_test.go index 3ba8072..fb9de93 100644 --- a/generator_test.go +++ b/generator_test.go @@ -61,6 +61,15 @@ func TestOptions(t *testing.T) { t.Fatal("expected noStyles to be true") } }) + t.Run("WithFieldNames", func(t *testing.T) { + g, err := newGenerator("stub", 1, withFieldNames()) + if err != nil { + t.Fatal("new generator error", err) + } + if g.fieldNames != true { + t.Fatal("expected fieldNames to be true") + } + }) t.Run("empty", func(t *testing.T) { g, err := newGenerator("stub", 1) if err != nil { diff --git a/inspector.go b/inspector.go index ffcd07d..8a5ad02 100644 --- a/inspector.go +++ b/inspector.go @@ -10,9 +10,10 @@ import ( ) 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 + 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 @@ -22,8 +23,8 @@ type inspector struct { err error } -func newInspector(typeName string, all bool, execLine int) *inspector { - return &inspector{typeName: typeName, all: all, execLine: execLine} +func newInspector(typeName string, all bool, execLine int, useFieldNames bool) *inspector { + return &inspector{typeName: typeName, all: all, execLine: execLine, useFieldNames: useFieldNames} } func (i *inspector) inspectFile(fileName string) ([]*EnvScope, error) { @@ -44,19 +45,17 @@ func (i *inspector) inspect(node ast.Node) ([]*EnvScope, error) { return i.items, i.err } -func (i *inspector) getScope(t *ast.TypeSpec) (out *EnvScope) { +func (i *inspector) getScope(t *ast.TypeSpec) *EnvScope { typeName := t.Name.Name for _, s := range i.items { if s.typeName == typeName { - out = s - return + return s } } - out = new(EnvScope) - parseType(t, i.doc, out) - i.items = append(i.items, out) - return + s := i.parseType(t) + i.items = append(i.items, s) + return s } func (i *inspector) Visit(n ast.Node) ast.Visitor { @@ -114,11 +113,11 @@ func (i *inspector) Visit(n ast.Node) ast.Visitor { if st, ok := t.Type.(*ast.StructType); ok { scope := i.getScope(t) for _, field := range st.Fields.List { - var item EnvDocItem - if !parseField(field, &item) { + items := i.parseField(field) + if len(items) == 0 { continue } - scope.Vars = append(scope.Vars, item) + scope.Vars = append(scope.Vars, items...) } } // reset pending type flag event if this type @@ -128,19 +127,22 @@ func (i *inspector) Visit(n ast.Node) ast.Visitor { return i } -func parseType(t *ast.TypeSpec, doc *doc.Package, out *EnvScope) { +func (i *inspector) parseType(t *ast.TypeSpec) *EnvScope { typeName := t.Name.Name - out.Doc = strings.TrimSpace(t.Doc.Text()) - if out.Doc == "" { - for _, t := range doc.Types { + docStr := strings.TrimSpace(t.Doc.Text()) + if docStr == "" { + for _, t := range i.doc.Types { if t.Name == typeName { - out.Doc = strings.TrimSpace(t.Doc) + docStr = strings.TrimSpace(t.Doc) break } } } - out.Name = typeName - out.typeName = typeName + return &EnvScope{ + Name: typeName, + Doc: docStr, + typeName: typeName, + } } func getTagValues(tag, tagName string) []string { @@ -161,40 +163,54 @@ func getTagValues(tag, tagName string) []string { return strings.Split(tagValue, ",") } -func parseField(f *ast.Field, out *EnvDocItem) bool { - if f.Tag == nil { - return false +func (i *inspector) parseField(f *ast.Field) (out []EnvDocItem) { + if f.Tag == nil && !i.useFieldNames { + return } - tag := f.Tag.Value - if !strings.Contains(tag, "env:") { - return false + var tag string + if t := f.Tag; t != nil { + tag = t.Value + } + if !strings.Contains(tag, "env:") && !i.useFieldNames { + return } tagValues := getTagValues(tag, "env") - if len(tagValues) == 0 { - return false + if len(tagValues) > 0 && tagValues[0] != "" { + var item EnvDocItem + item.Name = tagValues[0] + out = []EnvDocItem{item} + } else if i.useFieldNames { + out = make([]EnvDocItem, len(f.Names)) + for i, name := range f.Names { + out[i].Name = camelToSnake(name.Name) + } + } else { + return } - out.Name = tagValues[0] - if f.Doc != nil { - out.Doc = strings.TrimSpace(f.Doc.Text()) + docStr := strings.TrimSpace(f.Doc.Text()) + if docStr == "" { + docStr = strings.TrimSpace(f.Comment.Text()) } - if out.Doc == "" && f.Comment != nil { - out.Doc = strings.TrimSpace(f.Comment.Text()) + for i := range out { + out[i].Doc = docStr } var opts EnvVarOptions - for _, tagValue := range tagValues[1:] { - switch tagValue { - case "required": - opts.Required = true - case "expand": - opts.Expand = true - case "notEmpty": - opts.Required = true - opts.NonEmpty = true - case "file": - opts.FromFile = true + if len(tagValues) > 1 { + for _, tagValue := range tagValues[1:] { + switch tagValue { + case "required": + opts.Required = true + case "expand": + opts.Expand = true + case "notEmpty": + opts.Required = true + opts.NonEmpty = true + case "file": + opts.FromFile = true + } } } @@ -212,7 +228,8 @@ func parseField(f *ast.Field, out *EnvDocItem) bool { opts.Separator = "," } - out.Opts = opts - - return true + for i := range out { + out[i].Opts = opts + } + return } diff --git a/inspector_test.go b/inspector_test.go index 7804cea..7ee4320 100644 --- a/inspector_test.go +++ b/inspector_test.go @@ -2,6 +2,7 @@ package main import ( "embed" + "errors" "fmt" "go/ast" "io" @@ -12,9 +13,12 @@ import ( func TestTagParsers(t *testing.T) { type testCase struct { - tag string - expect EnvDocItem - fail bool + tag string + names []string + useFieldNames bool + expect EnvDocItem + expectList []EnvDocItem + fail bool } for i, c := range []testCase{ {tag: "", fail: true}, @@ -60,24 +64,86 @@ func TestTagParsers(t *testing.T) { Opts: EnvVarOptions{Separator: ";"}, }, }, + { + names: []string{"Foo", "BarBaz"}, + expectList: []EnvDocItem{ + {Name: "FOO"}, + {Name: "BAR_BAZ"}, + }, + useFieldNames: true, + }, + { + names: []string{"Foo"}, + tag: `env:",required"`, + expectList: []EnvDocItem{ + {Name: "FOO", Opts: EnvVarOptions{Required: true}}, + }, + useFieldNames: true, + }, } { t.Run(fmt.Sprint(i), func(t *testing.T) { - var out EnvDocItem + fieldNames := make([]*ast.Ident, len(c.names)) + for i, name := range c.names { + fieldNames[i] = &ast.Ident{Name: name} + } + var tag *ast.BasicLit + if c.tag != "" { + tag = &ast.BasicLit{Value: c.tag} + } field := &ast.Field{ - Tag: &ast.BasicLit{Value: c.tag}, + Tag: tag, + Names: fieldNames, } - ok := parseField(field, &out) - if ok != !c.fail { - t.Error("parseTag returned false") + i := inspector{ + useFieldNames: c.useFieldNames, } - if out != c.expect { - t.Errorf("parseTag of `%s` returned wrong result: %+v; expected: %+v", c.tag, out, c.expect) + + expect := c.expectList + if len(expect) == 0 && c.expect.Name != "" { + expect = []EnvDocItem{c.expect} + } + + actual := i.parseField(field) + if c.fail { + if actual != nil { + t.Errorf("expected nil, got %#v", actual) + } + return + } + if len(expect) != len(actual) { + t.Errorf("expected %d items, got %d", len(expect), len(actual)) + } + for i, e := range expect { + a := actual[i] + if e.Name != a.Name { + t.Errorf("expected[%d] name %q, got %q", i, e.Name, a.Name) + } + if e.Doc != a.Doc { + t.Errorf("expected[%d] doc %q, got %q", i, e.Doc, a.Doc) + } + if e.Opts != a.Opts { + t.Errorf("expected[%d] opts %#v, got %#v", i, e.Opts, a.Opts) + } } }) } } +func TestInspectorError(t *testing.T) { + sourceFile := path.Join(t.TempDir(), "tmp.go") + if err := copyTestFile(path.Join("testdata", "type.go"), sourceFile); err != nil { + t.Fatal("Copy test file data", err) + } + insp := newInspector("", true, 0, false) + targetErr := errors.New("target error") + insp.err = targetErr + _, err := insp.inspectFile(sourceFile) + if err != targetErr { + t.Errorf("Expected error %q, got %q", targetErr, err) + } +} + //go:embed testdata var testdata embed.FS @@ -252,7 +318,7 @@ func inspectorTester(name string, typeName string, all bool, lineN int, expect [ if err := copyTestFile(path.Join("testdata", name), sourceFile); err != nil { t.Fatal("Copy test file data", err) } - insp := newInspector(typeName, all, lineN) + insp := newInspector(typeName, all, lineN, false) scopes, err := insp.inspectFile(sourceFile) if err != nil { t.Fatal("Inspector failed", err) diff --git a/main.go b/main.go index 77bbb46..3d53aae 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ type appConfig struct { all bool envPrefix string noStyles bool + fieldNames bool } func (cfg *appConfig) parseFlags(f *flag.FlagSet) error { @@ -25,6 +26,7 @@ func (cfg *appConfig) parseFlags(f *flag.FlagSet) error { f.BoolVar(&cfg.all, "all", false, "Generate documentation for all types in the file") f.StringVar(&cfg.envPrefix, "env-prefix", "", "Environment variable prefix") f.BoolVar(&cfg.noStyles, "no-styles", false, "Disable styles in html output") + f.BoolVar(&cfg.fieldNames, "field-names", false, "Use field names if tag is not specified") if err := f.Parse(os.Args[1:]); err != nil { return fmt.Errorf("parsing CLI args: %w", err) } @@ -104,6 +106,9 @@ func run(cfg *appConfig) (err error) { if cfg.noStyles { opts = append(opts, withNoStyles()) } + if cfg.fieldNames { + opts = append(opts, withFieldNames()) + } gen, err := newGenerator(cfg.inputFileName, cfg.execLine, opts...) if err != nil { return fmt.Errorf("creating generator: %w", err) diff --git a/main_test.go b/main_test.go index 2aeb788..6cc0827 100644 --- a/main_test.go +++ b/main_test.go @@ -16,6 +16,7 @@ func TestConfig(t *testing.T) { "-no-styles", "-format", "markdown", "-env-prefix", "TEST_", + "-field-names", "-all", } t.Setenv("GOFILE", "test.go") @@ -43,6 +44,9 @@ func TestConfig(t *testing.T) { if !cfg.noStyles { t.Fatal("Invalid no styles flag") } + if !cfg.fieldNames { + t.Fatal("Invalid field names flag") + } if err := cfg.parseEnv(); err != nil { t.Fatal("Invalid environment:", err) @@ -129,9 +133,31 @@ func TestMainRun(t *testing.T) { inputFileName: inputFile, execLine: 0, envPrefix: "TEST_", + noStyles: true, + fieldNames: true, + all: true, } if err := run(&config); err != nil { t.Fatal("run", err) } }) + t.Run("bad-out", func(t *testing.T) { + inputFile := path.Join(t.TempDir(), "example.go") + if err := copyTestFile(path.Join("testdata", "type.go"), inputFile); err != nil { + t.Fatal("copy test file", err) + } + config := appConfig{ + typeName: "Type1", + formatName: "markdown", + outputFileName: "", + inputFileName: inputFile, + execLine: 0, + envPrefix: "TEST_", + } + err := run(&config) + if err == nil { + t.Fatal("Expect error for invalid output file name") + } + t.Logf("Got error as expected: %v", err) + }) } diff --git a/utils.go b/utils.go index e8c28d3..d28df46 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,10 @@ package main -import "io" +import ( + "io" + "strings" + "unicode" +) func closeWith(closer io.Closer, handler func(error)) { err := closer.Close() @@ -8,3 +12,20 @@ func closeWith(closer io.Closer, handler func(error)) { handler(err) } } + +func camelToSnake(s string) string { + const underscore = '_' + var result strings.Builder + result.Grow(len(s) + 5) + + var prev rune + for i, r := range s { + if i > 0 && prev != underscore && r != underscore && unicode.IsUpper(r) { + result.WriteRune(underscore) + } + result.WriteRune(unicode.ToUpper(r)) + prev = r + } + + return result.String() +} diff --git a/utils_test.go b/utils_test.go index d36e035..fede6b5 100644 --- a/utils_test.go +++ b/utils_test.go @@ -32,3 +32,23 @@ func TestCloseWith(t *testing.T) { t.Fatalf("expected handler to be called with %q, got %q", targetErr, handlerErr) } } + +func TestCamelToSnake(t *testing.T) { + type testCase [2]string + for _, tc := range []testCase{ + {"Foo", "FOO"}, + {"FooBar", "FOO_BAR"}, + {"FooBarBaz", "FOO_BAR_BAZ"}, + {"fooBar", "FOO_BAR"}, + {"fooBarBaz", "FOO_BAR_BAZ"}, + {"", ""}, + {"Foo_", "FOO_"}, + {"Foo_Bar", "FOO_BAR"}, + {"Foo_bar", "FOO_BAR"}, + {"Foo_Bar_Baz", "FOO_BAR_BAZ"}, + } { + if got := camelToSnake(tc[0]); got != tc[1] { + t.Fatalf("expected camelToSnake(%q) to be %q, got %q", tc[0], tc[1], got) + } + } +}