From 4f63b96de98b373dae1b492c81cda8e4cdb0d1d2 Mon Sep 17 00:00:00 2001 From: Ryan Ball Date: Thu, 18 Jun 2026 15:05:59 -0600 Subject: [PATCH] feat: promote embedded (anonymous) structs across loaders Embedded struct fields are now flattened/promoted consistently instead of being prefixed with the embedded type name. The env and flag loaders previously prefixed embedded fields (EMBEDDED_DB_HOST), while json/toml flattened them, leaving behavior inconsistent across formats. The env, flag, and render name generators now drop the type-name prefix for embedded struct fields (guarded to structs only, excluding time.Time and embedded basic types), matching json/toml and Go's encoding/json promotion. yaml requires a yaml:",inline" tag (yaml.v2 limitation). Also document every supported struct field tag with its accepted values and examples, and add an embedded-struct section to the README. --- README.md | 129 ++++++++++++++++++++++++++++++++++++++++------ config_test.go | 95 ++++++++++++++++++++++++++++++++++ load/env/env.go | 7 +++ load/flag/flag.go | 7 +++ render/render.go | 5 ++ util/node/node.go | 6 +++ 6 files changed, 234 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 20e19fc..befb5ff 100644 --- a/README.md +++ b/README.md @@ -151,21 +151,57 @@ export DB_PW= # The db password. ## Struct Tags -Per-field behavior is controlled with struct tags: - -| Tag | Description | -| -------- | -------------------------------------------------------------------------------------- | -| `env` | Override the env var name. Use `-` to ignore, or `omitprefix` on a struct (see below). | -| `flag` | Override the flag name. Supports a short alias, e.g. `flag:"username,u"`. Use `-` to ignore. | -| `toml` | Override the TOML key name. | -| `yaml` | Override the YAML key name. | -| `json` | Override the JSON key name. | -| `help` | Help text shown in the `--help` screen and as comments in generated templates. | -| `fmt` | Time format for `time.Time` fields (Go reference layout or names like `RFC3339`). | -| `sep` | Separator used for slice values (default is `,`). | -| `show` | Set to `false` to redact a value when printing loaded config (e.g. secrets). | -| `req` | Set to `true` to mark a field as required. | -| `ignore` | Set to `true` to ignore a field entirely (equivalent to `config:"ignore"`). | +Per-field behavior is controlled with struct tags. The table below lists every supported tag, its +accepted values, and an example. + +| Tag | Supported values | Example | +| -------- | ------------------------------------------------------------------------------------------------- | -------------------------------- | +| `env` | A custom env var name; `-` to ignore; `omitprefix` (struct fields only); optional `,string` suffix | `env:"DB_HOST"`, `env:"-"` | +| `flag` | A custom flag name, optionally `name,alias` (alias must be one char); `-` to ignore; `omitprefix`; optional `,string` suffix | `flag:"username,u"`, `flag:"-"` | +| `toml` | A custom TOML key name | `toml:"db_host"` | +| `yaml` | A custom YAML key name; `,inline` to promote an embedded struct | `yaml:"db_host"`, `yaml:",inline"` | +| `json` | A custom JSON key name | `json:"db_host"` | +| `help` | Any text (shown in `--help` and as comments in generated templates) | `help:"The db host:port."` | +| `fmt` | A Go time layout, or a named layout (`RFC3339`, `RFC1123`, `Kitchen`, `UnixDate`, etc.) for `time.Time` fields | `fmt:"RFC3339"`, `fmt:"2006-01-02"` | +| `sep` | Any separator string used for slice values (default `,`) | `sep:";"` | +| `show` | `true` or `false` — set `false` to redact the value when printing config (default `true`) | `show:"false"` | +| `req` | `true` or `false` — annotate the field as required in the help/printed output, e.g. `(required)` (default `false`; not auto-enforced — use the `Validator` interface to enforce) | `req:"true"` | +| `ignore` | `true` or `false` — ignore the field entirely (default `false`) | `ignore:"true"` | +| `config` | `ignore` to ignore the field entirely (alternative to `ignore:"true"`) | `config:"ignore"` | + +Notes: + +- **`,string` suffix** (env and flag only): treats slice elements as quoted strings, so values keep + surrounding quotes when generated and have them stripped when parsed — e.g. `env:"NAMES,string"`. +- **`-` vs `omitprefix`**: `-` ignores a field for that loader; `omitprefix` keeps the field but drops + the struct's name from the generated prefix (see [Omitting a struct prefix](#omitting-a-struct-prefix)). +- A field with no tag for a given loader uses its struct field name, re-cased to the loader's + convention (e.g. `SCREAMING_SNAKE` for env, `kebab-case` for flags). + +### All tags at a glance + +```go +type Config struct { + // env/flag/file name overrides + a short flag alias. + Host string `env:"DB_HOST" flag:"host,H" toml:"db_host" yaml:"db_host" json:"db_host" help:"The db host:port." req:"true"` + + // Redacted when printing loaded config. + Password string `flag:"pw,p" help:"The db password." show:"false"` + + // time.Time with a named layout. + StartAt time.Time `fmt:"RFC3339"` + + // Slice with a custom separator. + Tags []string `sep:";"` + + // Ignored entirely (both forms are equivalent). + Internal string `ignore:"true"` + Internal2 string `config:"ignore"` + + // Ignored only for specific loaders. + OnlyFromFile string `env:"-" flag:"-"` +} +``` ### Nested structs and env prefixes @@ -245,6 +281,69 @@ export UN= # no prefix export PW= # no prefix ``` +### Embedded (anonymous) structs + +Embedding a struct **promotes** its fields, so they are treated as if they were declared directly on the +parent — there is no type-name prefix. This mirrors how Go's own `encoding/json` handles embedded structs. + +```go +type DB struct { + Host string + Port int +} + +type Config struct { + DB // embedded (anonymous) + Name string +} +``` + +With the struct above, the fields are promoted across every loader: + +```sh +# env (no "DB_" prefix) +export HOST=localhost +export PORT=5432 +export NAME=myapp +``` + +```sh +# flags (no "db-" prefix) +./myapp --host=localhost --port=5432 --name=myapp +``` + +```yaml +# yaml / json / toml (top-level, no "db" nesting) +host: localhost +port: 5432 +name: myapp +``` + +Two things to keep in mind: + +- **YAML requires an inline tag.** `gopkg.in/yaml.v2` does not inline anonymous structs automatically, so + add `yaml:",inline"` to the embedded field if you load YAML: + + ```go + type Config struct { + DB `yaml:",inline"` + Name string + } + ``` + +- **The embedded type must be exported.** Fields of an unexported embedded type (e.g. `db`) are not + settable via reflection and are skipped by the env and flag loaders. + +If you want the type name to act as a prefix instead of promoting the fields, use a **named** field rather +than embedding: + +```go +type Config struct { + DB DB // named field -> DB_HOST, --db-host, nested "db" in files + Name string +} +``` + ## Long Help Descriptions For longer help descriptions, call `FieldHelp`. Nested struct fields are addressed with a `.` between diff --git a/config_test.go b/config_test.go index f047d30..6df6329 100644 --- a/config_test.go +++ b/config_test.go @@ -77,3 +77,98 @@ func TestLoadFromFileExtensions(t *testing.T) { }) } } + +// EmbeddedDB is used to exercise embedded (anonymous) struct promotion. It must +// be exported so its fields are settable when embedded. +type EmbeddedDB struct { + Host string `yaml:"host" toml:"host" json:"host"` + Port int `yaml:"port" toml:"port" json:"port"` +} + +// embeddedConfig embeds EmbeddedDB. The yaml:",inline" tag is required for the +// yaml loader to promote the embedded fields (gopkg.in/yaml.v2 does not inline +// anonymous structs by default). +type embeddedConfig struct { + EmbeddedDB `yaml:",inline"` + Name string `yaml:"name" toml:"name" json:"name"` +} + +// TestEmbeddedStructPromotionEnv verifies that fields of an embedded +// (anonymous) struct are promoted in the env loader: the embedded type name is +// NOT used as a prefix. +func TestEmbeddedStructPromotionEnv(t *testing.T) { + os.Clearenv() + os.Setenv("HOST", "promoted-host") + os.Setenv("PORT", "5432") + os.Setenv("NAME", "myapp") + + c := &embeddedConfig{} + if err := New().DisableStdFlags().With("env").Load(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if c.Host != "promoted-host" { + t.Errorf("expected Host=%q from HOST env var, got %q", "promoted-host", c.Host) + } + if c.Port != 5432 { + t.Errorf("expected Port=5432 from PORT env var, got %d", c.Port) + } + if c.Name != "myapp" { + t.Errorf("expected Name=%q, got %q", "myapp", c.Name) + } +} + +// TestEmbeddedStructPromotionFiles verifies that embedded struct fields are +// promoted (flattened) consistently across the yaml, toml, and json loaders. +func TestEmbeddedStructPromotionFiles(t *testing.T) { + cases := []struct { + name string + file string + contents string + loader string + }{ + { + name: "yaml", + file: "config.yaml", + contents: "host: yaml-host\nport: 1\nname: yaml-name\n", + loader: "yaml", + }, + { + name: "toml", + file: "config.toml", + contents: "host = \"toml-host\"\nport = 2\nname = \"toml-name\"\n", + loader: "toml", + }, + { + name: "json", + file: "config.json", + contents: `{"host":"json-host","port":3,"name":"json-name"}`, + loader: "json", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + fpath := filepath.Join(dir, tc.file) + if err := os.WriteFile(fpath, []byte(tc.contents), 0o600); err != nil { + t.Fatalf("writing temp config: %v", err) + } + + c := &embeddedConfig{} + if err := New().DisableStdFlags().With(tc.loader).SetConfigPath(fpath).Load(c); err != nil { + t.Fatalf("unexpected error loading %q: %v", tc.file, err) + } + + if c.Host == "" { + t.Errorf("expected promoted Host to be populated from %q, got empty", tc.file) + } + if c.Port == 0 { + t.Errorf("expected promoted Port to be populated from %q, got 0", tc.file) + } + if c.Name == "" { + t.Errorf("expected Name to be populated from %q, got empty", tc.file) + } + }) + } +} diff --git a/load/env/env.go b/load/env/env.go index 4e94f46..41cdfe5 100644 --- a/load/env/env.go +++ b/load/env/env.go @@ -54,6 +54,13 @@ func nodeEnvName(n *node.Node) string { case "omitprefix": return "" case "": + // Embedded (anonymous) struct fields are promoted: their fields are + // treated as if declared on the parent, so they contribute no prefix. + // This matches how json/toml handle embedded structs and Go's own + // encoding/json promotion. An explicit env tag overrides this. + if n.IsAnonymous() && n.IsStruct() && !n.IsTime() { + return "" + } return util.ToScreamingSnake(n.FieldName()) default: return ev diff --git a/load/flag/flag.go b/load/flag/flag.go index 0e6e354..18e2f71 100644 --- a/load/flag/flag.go +++ b/load/flag/flag.go @@ -291,6 +291,13 @@ func nodeFlagName(n *node.Node) (name, alias string) { case "omitprefix": return "", "" case "": + // Embedded (anonymous) struct fields are promoted: their fields are + // treated as if declared on the parent, so they contribute no prefix. + // This matches how json/toml handle embedded structs and Go's own + // encoding/json promotion. An explicit flag tag overrides this. + if n.IsAnonymous() && n.IsStruct() && !n.IsTime() { + return "", "" + } return util.ToKebab(n.FieldName()), "" default: return name, alias diff --git a/render/render.go b/render/render.go index c40110c..4d8359f 100644 --- a/render/render.go +++ b/render/render.go @@ -596,6 +596,11 @@ func nodeName(n *node.Node, nameFrom, formatAs string) string { } if name == "" { + // Embedded (anonymous) struct fields are promoted: they contribute no + // name segment, consistent with how the env/flag loaders treat them. + if n.IsAnonymous() && n.IsStruct() && !n.IsTime() { + return "" + } name = n.FieldName() } diff --git a/util/node/node.go b/util/node/node.go index ba632bb..200bd26 100644 --- a/util/node/node.go +++ b/util/node/node.go @@ -204,6 +204,12 @@ func (n *Node) IsString() bool { return n.Kind() == reflect.String } +// IsAnonymous reports whether the node represents an embedded (anonymous) +// struct field. +func (n *Node) IsAnonymous() bool { + return n.Field.Anonymous +} + func (n *Node) IsDuration() bool { return n.ValueType() == "time.Duration" }