From c043282d9017a3fb7f0dcf2807937acf88a2c6fd Mon Sep 17 00:00:00 2001 From: Sutina Wipawiwat Date: Thu, 5 Feb 2026 13:25:59 +1100 Subject: [PATCH 1/2] feat: add WithVariableExpansion to expand in default struct tags --- marshal.go | 35 ++++++++++++++++++------- unmarshal.go | 4 +-- unmarshal_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/marshal.go b/marshal.go index e664a96..1912477 100644 --- a/marshal.go +++ b/marshal.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "math/big" + "os" "reflect" "regexp" "sort" @@ -26,6 +27,7 @@ type marshalState struct { schemaComments bool seenStructs map[reflect.Type]bool allowExtra bool + variableExpander func(string) string } // Create a shallow clone with schema overridden. @@ -84,6 +86,17 @@ func WithSchemaComments(v bool) MarshalOption { } } +func WithVariableExpansion(vars map[string]string) MarshalOption { + return func(options *marshalState) { + options.variableExpander = func(key string) string { + if val, ok := vars[key]; ok { + return val + } + return os.Getenv(key) + } + } +} + func asSchema() MarshalOption { return func(options *marshalState) { options.schema = true @@ -264,17 +277,17 @@ func fieldToAttr(field field, tag tag, opt *marshalState) (*Attribute, error) { if err != nil { return nil, err } - attr.Default, err = defaultValueFromTag(field, tag.defaultValue) + attr.Default, err = defaultValueFromTag(field, tag.defaultValue, opt) if err != nil { return nil, err } attr.Optional = (tag.optional || attr.Default != nil) && opt.schema - attr.Enum, err = enumValuesFromTag(field, tag.enum) + attr.Enum, err = enumValuesFromTag(field, tag.enum, opt) return attr, err } -func defaultValueFromTag(f field, defaultValue string) (Value, error) { - v, err := valueFromTag(f, defaultValue) +func defaultValueFromTag(f field, defaultValue string, opt *marshalState) (Value, error) { + v, err := valueFromTag(f, defaultValue, opt) if err != nil { return nil, fmt.Errorf("error parsing default value: %v", err) } @@ -282,7 +295,7 @@ func defaultValueFromTag(f field, defaultValue string) (Value, error) { } // enumValuesFromTag parses the enum string from tag into a list of Values -func enumValuesFromTag(f field, enum string) ([]Value, error) { +func enumValuesFromTag(f field, enum string, opt *marshalState) ([]Value, error) { if enum == "" { return nil, nil } @@ -290,7 +303,7 @@ func enumValuesFromTag(f field, enum string) ([]Value, error) { enums := strings.Split(enum, ",") list := make([]Value, 0, len(enums)) for _, e := range enums { - enumVal, err := valueFromTag(f, e) + enumVal, err := valueFromTag(f, e, opt) if err != nil { return nil, fmt.Errorf("error parsing enum: %v", err) } @@ -302,11 +315,15 @@ func enumValuesFromTag(f field, enum string) ([]Value, error) { } -func valueFromTag(f field, defaultValue string) (Value, error) { +func valueFromTag(f field, defaultValue string, opt *marshalState) (Value, error) { if defaultValue == "" { return nil, nil // nolint: nilnil } + if opt != nil && opt.variableExpander != nil { + defaultValue = os.Expand(defaultValue, opt.variableExpander) + } + k := f.v.Kind() if k == reflect.Ptr { k = f.v.Type().Elem().Kind() @@ -372,7 +389,7 @@ func valueFromTag(f field, defaultValue string) (Value, error) { t: reflect.StructField{}, v: reflect.New(valueType), } - val, err := defaultValueFromTag(valueField, v) + val, err := defaultValueFromTag(valueField, v, opt) if err != nil { return nil, fmt.Errorf("error parsing map %q into value, %v", v, err) } @@ -406,7 +423,7 @@ func valueFromTag(f field, defaultValue string) (Value, error) { v: reflect.New(valueType), } for _, item := range list { - value, err := defaultValueFromTag(valueField, item) + value, err := defaultValueFromTag(valueField, item, opt) if err != nil { return nil, fmt.Errorf("error applying %q to list: %v", item, err) } diff --git a/unmarshal.go b/unmarshal.go index 3b4b259..d61cf74 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -129,7 +129,7 @@ func unmarshalEntries(v reflect.Value, entries []Entry, opt *marshalState) error return fmt.Errorf("missing required attribute %q", tag.name) } // apply defaults here as there's no value for this field - v, err := defaultValueFromTag(field, tag.defaultValue) + v, err := defaultValueFromTag(field, tag.defaultValue, opt) if err != nil { return err } @@ -297,7 +297,7 @@ func checkEnum(v Value, f field, enum string) error { case reflect.Map, reflect.Struct, reflect.Array, reflect.Slice: return fmt.Errorf("enum on map, struct, array and slice are not supported on field %q", f.t.Name) default: - enums, err := enumValuesFromTag(f, enum) + enums, err := enumValuesFromTag(f, enum, nil) if err != nil { return err } diff --git a/unmarshal_test.go b/unmarshal_test.go index cb22765..e051be6 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -1102,3 +1102,69 @@ func TestUnmarshallInterfaces(t *testing.T) { } runTests(t, tests) } + +func TestWithVariableExpansion(t *testing.T) { + type TestConfig struct { + BaseURL string `hcl:"base-url,optional" default:"${BASE_URL}/api/v1"` + Port int `hcl:"port,optional" default:"${PORT}"` + Debug bool `hcl:"debug,optional" default:"${DEBUG}"` + } + + vars := map[string]string{ + "BASE_URL": "https://api.example.com", + "PORT": "8080", + "DEBUG": "true", + } + + // Empty HCL block - should get defaults with expanded variables + block := &Block{ + Name: "test", + Body: []Entry{}, // Empty body + } + + var config TestConfig + err := UnmarshalBlock(block, &config, WithVariableExpansion(vars)) + assert.NoError(t, err) + + assert.Equal(t, "https://api.example.com/api/v1", config.BaseURL) + assert.Equal(t, 8080, config.Port) + assert.Equal(t, true, config.Debug) + + type ComplexConfig struct { + APIEndpoint string `hcl:"api-endpoint,optional" default:"https://${HOST}:${PORT}/api"` + } + + complexVars := map[string]string{ + "HOST": "api.service.com", + "PORT": "8443", + } + + var complexConfig ComplexConfig + err = UnmarshalBlock(&Block{Name: "test", Body: []Entry{}}, &complexConfig, + WithVariableExpansion(complexVars)) + assert.NoError(t, err) + assert.Equal(t, "https://api.service.com:8443/api", complexConfig.APIEndpoint) + + type UndefinedConfig struct { + Value string `hcl:"value,optional" default:"prefix_${UNDEFINED_VAR}_suffix"` + Simple string `hcl:"simple,optional" default:"${TOTALLY_UNDEFINED}"` + } + + var undefinedConfig UndefinedConfig + err = UnmarshalBlock(&Block{Name: "test", Body: []Entry{}}, &undefinedConfig, + WithVariableExpansion(map[string]string{})) + assert.NoError(t, err) + assert.Equal(t, "prefix__suffix", undefinedConfig.Value) + assert.Equal(t, "", undefinedConfig.Simple) + t.Setenv("FALLBACK_VAR", "fallback-value") + + type FallbackConfig struct { + Value string `hcl:"value,optional" default:"${FALLBACK_VAR}"` + } + + var fallbackConfig FallbackConfig + err = UnmarshalBlock(&Block{Name: "test", Body: []Entry{}}, &fallbackConfig, + WithVariableExpansion(map[string]string{})) + assert.NoError(t, err) + assert.Equal(t, "fallback-value", fallbackConfig.Value) +} From 7b6ba2bb10642ca6386b10654a3158eb5bd161e1 Mon Sep 17 00:00:00 2001 From: Sutina Wipawiwat Date: Thu, 5 Feb 2026 14:24:41 +1100 Subject: [PATCH 2/2] refactor: Change the implementation to generic WithDefaultTransformer --- marshal.go | 17 +++--- unmarshal_test.go | 135 ++++++++++++++++++++++++++-------------------- 2 files changed, 83 insertions(+), 69 deletions(-) diff --git a/marshal.go b/marshal.go index 1912477..81d6997 100644 --- a/marshal.go +++ b/marshal.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "math/big" - "os" "reflect" "regexp" "sort" @@ -27,7 +26,7 @@ type marshalState struct { schemaComments bool seenStructs map[reflect.Type]bool allowExtra bool - variableExpander func(string) string + defaultTransformer func(string) string } // Create a shallow clone with schema overridden. @@ -86,14 +85,10 @@ func WithSchemaComments(v bool) MarshalOption { } } -func WithVariableExpansion(vars map[string]string) MarshalOption { +// WithDefaultTransformer allows custom processing of default values in struct tags. +func WithDefaultTransformer(transformer func(string) string) MarshalOption { return func(options *marshalState) { - options.variableExpander = func(key string) string { - if val, ok := vars[key]; ok { - return val - } - return os.Getenv(key) - } + options.defaultTransformer = transformer } } @@ -320,8 +315,8 @@ func valueFromTag(f field, defaultValue string, opt *marshalState) (Value, error return nil, nil // nolint: nilnil } - if opt != nil && opt.variableExpander != nil { - defaultValue = os.Expand(defaultValue, opt.variableExpander) + if opt != nil && opt.defaultTransformer != nil { + defaultValue = opt.defaultTransformer(defaultValue) } k := f.v.Kind() diff --git a/unmarshal_test.go b/unmarshal_test.go index e051be6..d8bd5d4 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -3,6 +3,7 @@ package hcl import ( "fmt" "net" + "os" "reflect" "sort" "strconv" @@ -1103,68 +1104,86 @@ func TestUnmarshallInterfaces(t *testing.T) { runTests(t, tests) } -func TestWithVariableExpansion(t *testing.T) { - type TestConfig struct { - BaseURL string `hcl:"base-url,optional" default:"${BASE_URL}/api/v1"` - Port int `hcl:"port,optional" default:"${PORT}"` - Debug bool `hcl:"debug,optional" default:"${DEBUG}"` - } - - vars := map[string]string{ - "BASE_URL": "https://api.example.com", - "PORT": "8080", - "DEBUG": "true", - } - - // Empty HCL block - should get defaults with expanded variables - block := &Block{ - Name: "test", - Body: []Entry{}, // Empty body - } - - var config TestConfig - err := UnmarshalBlock(block, &config, WithVariableExpansion(vars)) - assert.NoError(t, err) - - assert.Equal(t, "https://api.example.com/api/v1", config.BaseURL) - assert.Equal(t, 8080, config.Port) - assert.Equal(t, true, config.Debug) - - type ComplexConfig struct { - APIEndpoint string `hcl:"api-endpoint,optional" default:"https://${HOST}:${PORT}/api"` - } - - complexVars := map[string]string{ - "HOST": "api.service.com", - "PORT": "8443", - } - - var complexConfig ComplexConfig - err = UnmarshalBlock(&Block{Name: "test", Body: []Entry{}}, &complexConfig, - WithVariableExpansion(complexVars)) - assert.NoError(t, err) - assert.Equal(t, "https://api.service.com:8443/api", complexConfig.APIEndpoint) - - type UndefinedConfig struct { - Value string `hcl:"value,optional" default:"prefix_${UNDEFINED_VAR}_suffix"` - Simple string `hcl:"simple,optional" default:"${TOTALLY_UNDEFINED}"` +func TestWithDefaultTransformer(t *testing.T) { + expandVars := func(vars map[string]string) func(string) string { + return func(defaultValue string) string { + return os.Expand(defaultValue, func(key string) string { + if val, ok := vars[key]; ok { + return val + } + return os.Getenv(key) + }) + } } - var undefinedConfig UndefinedConfig - err = UnmarshalBlock(&Block{Name: "test", Body: []Entry{}}, &undefinedConfig, - WithVariableExpansion(map[string]string{})) - assert.NoError(t, err) - assert.Equal(t, "prefix__suffix", undefinedConfig.Value) - assert.Equal(t, "", undefinedConfig.Simple) t.Setenv("FALLBACK_VAR", "fallback-value") - type FallbackConfig struct { - Value string `hcl:"value,optional" default:"${FALLBACK_VAR}"` + tests := []test{ + { + name: "shell-style variable expansion", + hcl: ``, + dest: struct { + BaseURL string `hcl:"base-url,optional" default:"${BASE_URL}/api/v1"` + }{ + BaseURL: "https://api.example.com/api/v1", + }, + options: []MarshalOption{WithDefaultTransformer(expandVars(map[string]string{ + "BASE_URL": "https://api.example.com", + }))}, + }, + { + name: "custom template syntax", + hcl: ``, + dest: struct { + Message string `hcl:"message,optional" default:"Hello {{NAME}}!"` + }{ + Message: "Hello World!", + }, + options: []MarshalOption{WithDefaultTransformer(func(s string) string { + if s == "Hello {{NAME}}!" { + return "Hello World!" + } + return s + })}, + }, + { + name: "string replacement", + hcl: ``, + dest: struct { + Path string `hcl:"path,optional" default:"__HOME__/config"` + }{ + Path: "/home/user/config", + }, + options: []MarshalOption{WithDefaultTransformer(func(s string) string { + if s == "__HOME__/config" { + return "/home/user/config" + } + return s + })}, + }, + { + name: "prefix addition", + hcl: ``, + dest: struct { + Topic string `hcl:"topic,optional" default:"events"` + }{ + Topic: "prod.events", + }, + options: []MarshalOption{WithDefaultTransformer(func(s string) string { + return "prod." + s + })}, + }, + { + name: "environment variable fallback", + hcl: ``, + dest: struct { + Value string `hcl:"value,optional" default:"${FALLBACK_VAR}"` + }{ + Value: "fallback-value", + }, + options: []MarshalOption{WithDefaultTransformer(expandVars(map[string]string{}))}, + }, } - var fallbackConfig FallbackConfig - err = UnmarshalBlock(&Block{Name: "test", Body: []Entry{}}, &fallbackConfig, - WithVariableExpansion(map[string]string{})) - assert.NoError(t, err) - assert.Equal(t, "fallback-value", fallbackConfig.Value) + runTests(t, tests) }