From c7474df351ad284811ecca3712d6fa30b32a9e42 Mon Sep 17 00:00:00 2001 From: Yurun Date: Wed, 27 May 2026 11:50:16 +0800 Subject: [PATCH 1/2] feat: add customizable ParseBool and FormatBool in LoadOptions --- ini.go | 4 +++ key.go | 25 ++++++++++++-- key_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++ struct.go | 6 ++-- 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/ini.go b/ini.go index 99e7f86..ab91fb1 100644 --- a/ini.go +++ b/ini.go @@ -125,6 +125,10 @@ type LoadOptions struct { AllowNonUniqueSections bool // AllowDuplicateShadowValues indicates whether values for shadowed keys should be deduplicated. AllowDuplicateShadowValues bool + // ParseBool is a function that parses boolean strings. When nil, the built-in boolean parser is used. + ParseBool func(string) (bool, error) + // FormatBool is a function that formats boolean values as strings. When nil, strconv.FormatBool is used. + FormatBool func(bool) string } // DebugFunc is the type of function called to log parse events. diff --git a/key.go b/key.go index b1ef5c9..c419b5f 100644 --- a/key.go +++ b/key.go @@ -191,18 +191,26 @@ func (k *Key) Validate(fn func(string) string) string { // It accepts 1, t, T, TRUE, true, True, YES, yes, Yes, y, ON, on, On, // 0, f, F, FALSE, false, False, NO, no, No, n, OFF, off, Off. // Any other value returns an error. -func parseBool(str string) (value bool, err error) { +func parseBool(str string, customParseBool ...func(string) (bool, error)) (value bool, err error) { + if len(customParseBool) > 0 && customParseBool[0] != nil { + return customParseBool[0](str) + } + switch str { case "1", "t", "T", "true", "TRUE", "True", "YES", "yes", "Yes", "y", "ON", "on", "On": return true, nil case "0", "f", "F", "false", "FALSE", "False", "NO", "no", "No", "n", "OFF", "off", "Off": return false, nil } + return false, fmt.Errorf("parsing \"%s\": invalid syntax", str) } // Bool returns bool type value. func (k *Key) Bool() (bool, error) { + if k != nil && k.s != nil && k.s.f != nil { + return parseBool(k.String(), k.s.f.options.ParseBool) + } return parseBool(k.String()) } @@ -248,6 +256,13 @@ func (k *Key) Time() (time.Time, error) { return k.TimeFormat(time.RFC3339) } +func (k *Key) formatBool(value bool) string { + if k != nil && k.s != nil && k.s.f != nil && k.s.f.options.FormatBool != nil { + return k.s.f.options.FormatBool(value) + } + return strconv.FormatBool(value) +} + // MustString returns default value if key value is empty. func (k *Key) MustString(defaultVal string) string { val := k.String() @@ -263,7 +278,7 @@ func (k *Key) MustString(defaultVal string) string { func (k *Key) MustBool(defaultVal ...bool) bool { val, err := k.Bool() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatBool(defaultVal[0]) + k.value = k.formatBool(defaultVal[0]) return defaultVal[0] } return val @@ -697,8 +712,12 @@ func (k *Key) StrictTimes(delim string) ([]time.Time, error) { // parseBools transforms strings to bools. func (k *Key) parseBools(strs []string, addInvalid, returnOnInvalid bool) ([]bool, error) { vals := make([]bool, 0, len(strs)) + var customParseBool func(string) (bool, error) + if k != nil && k.s != nil && k.s.f != nil { + customParseBool = k.s.f.options.ParseBool + } parser := func(str string) (interface{}, error) { - val, err := parseBool(str) + val, err := parseBool(str, customParseBool) return val, err } rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) diff --git a/key_test.go b/key_test.go index e57a7bd..cf8a234 100644 --- a/key_test.go +++ b/key_test.go @@ -529,6 +529,100 @@ func TestKey_Helpers(t *testing.T) { }) } +func TestKey_FormatBool(t *testing.T) { + formatBool := func(value bool) string { + if value { + return "enabled" + } + return "disabled" + } + + t.Run("must bool falls back to strconv format when formatter is nil", func(t *testing.T) { + f := Empty() + require.NotNil(t, f) + + key, err := f.Section("").NewKey("BOOL", "not-a-bool") + require.NoError(t, err) + require.NotNil(t, key) + + assert.True(t, key.MustBool(true)) + assert.Equal(t, "true", key.String()) + }) + + t.Run("must bool uses custom formatter when configured", func(t *testing.T) { + f := Empty(LoadOptions{FormatBool: formatBool}) + require.NotNil(t, f) + + key, err := f.Section("").NewKey("BOOL", "not-a-bool") + require.NoError(t, err) + require.NotNil(t, key) + + assert.True(t, key.MustBool(true)) + assert.Equal(t, "enabled", key.String()) + }) +} + +func TestKey_ParseBool(t *testing.T) { + parseBool := func(value string) (bool, error) { + switch value { + case "enabled": + return true, nil + case "disabled": + return false, nil + default: + return false, fmt.Errorf("parsing %q: invalid syntax", value) + } + } + + t.Run("bool parses custom text", func(t *testing.T) { + f := Empty(LoadOptions{ParseBool: parseBool}) + require.NotNil(t, f) + + t.Run("success", func(t *testing.T) { + key, err := f.Section("").NewKey("BOOL", "enabled") + require.NoError(t, err) + require.NotNil(t, key) + + value, err := key.Bool() + require.NoError(t, err) + assert.True(t, value) + }) + + t.Run("failure", func(t *testing.T) { + key, err := f.Section("").NewKey("BOOL_INVALID", "unknown") + require.NoError(t, err) + require.NotNil(t, key) + + value, err := key.Bool() + assert.Error(t, err) + assert.False(t, value) + }) + }) + + t.Run("bool slices parse custom text", func(t *testing.T) { + f := Empty(LoadOptions{ParseBool: parseBool}) + require.NotNil(t, f) + + t.Run("success", func(t *testing.T) { + key, err := f.Section("").NewKey("BOOLS", "enabled,disabled,enabled") + require.NoError(t, err) + require.NotNil(t, key) + + boolsEqual(t, key.Bools(","), true, false, true) + }) + + t.Run("failure", func(t *testing.T) { + invalidKey, err := f.Section("").NewKey("BOOLS_INVALID", "enabled,unknown") + require.NoError(t, err) + require.NotNil(t, invalidKey) + + vals, err := invalidKey.StrictBools(",") + assert.Empty(t, vals) + assert.Error(t, err) + }) + }) +} + func TestKey_ValueWithShadows(t *testing.T) { t.Run("", func(t *testing.T) { f, err := ShadowLoad([]byte(` diff --git a/struct.go b/struct.go index 819a6e5..db06070 100644 --- a/struct.go +++ b/struct.go @@ -477,7 +477,7 @@ func reflectSliceWithProperType(key *Key, field reflect.Value, delim string, all case reflect.Float64: val = fmt.Sprint(slice.Index(i).Float()) case reflect.Bool: - val = fmt.Sprint(slice.Index(i).Bool()) + val = key.formatBool(slice.Index(i).Bool()) case reflectTime: val = slice.Index(i).Interface().(time.Time).Format(time.RFC3339) default: @@ -506,7 +506,7 @@ func reflectSliceWithProperType(key *Key, field reflect.Value, delim string, all case reflect.Float64: fmt.Fprint(&buf, slice.Index(i).Float()) case reflect.Bool: - fmt.Fprint(&buf, slice.Index(i).Bool()) + buf.WriteString(key.formatBool(slice.Index(i).Bool())) case reflectTime: buf.WriteString(slice.Index(i).Interface().(time.Time).Format(time.RFC3339)) default: @@ -524,7 +524,7 @@ func reflectWithProperType(t reflect.Type, key *Key, field reflect.Value, delim case reflect.String: key.SetValue(field.String()) case reflect.Bool: - key.SetValue(fmt.Sprint(field.Bool())) + key.SetValue(key.formatBool(field.Bool())) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: key.SetValue(fmt.Sprint(field.Int())) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: From 9b5179ef5003cdf350acdf99540b8e759541d982 Mon Sep 17 00:00:00 2001 From: Yurun Date: Wed, 27 May 2026 13:18:36 +0800 Subject: [PATCH 2/2] add test --- ini_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/ini_test.go b/ini_test.go index a3d3b4c..65f9da8 100644 --- a/ini_test.go +++ b/ini_test.go @@ -487,6 +487,78 @@ key3`)) }) }) + t.Run("custom bool handling", func(t *testing.T) { + parseBool := func(value string) (bool, error) { + switch value { + case "enabled": + return true, nil + case "disabled": + return false, nil + default: + return false, assert.AnError + } + } + formatBool := func(value bool) string { + if value { + return "enabled" + } + return "disabled" + } + + f, err := LoadSources(LoadOptions{ + ParseBool: parseBool, + FormatBool: formatBool, + }, []byte(` +feature = enabled +disabled_feature = disabled +feature_list = enabled,disabled,enabled +invalid_feature = unknown +invalid_feature_false = unknown +invalid_feature_list = enabled,unknown`)) + require.NoError(t, err) + require.NotNil(t, f) + + t.Run("parse single true bool", func(t *testing.T) { + value, err := f.Section("").Key("feature").Bool() + require.NoError(t, err) + assert.True(t, value) + }) + + t.Run("parse single false bool", func(t *testing.T) { + value, err := f.Section("").Key("disabled_feature").Bool() + require.NoError(t, err) + assert.False(t, value) + }) + + t.Run("parse bool slice", func(t *testing.T) { + assert.Equal(t, []bool{true, false, true}, f.Section("").Key("feature_list").Bools(",")) + }) + + t.Run("fail to parse invalid bool", func(t *testing.T) { + value, err := f.Section("").Key("invalid_feature").Bool() + assert.Error(t, err) + assert.False(t, value) + }) + + t.Run("fail to parse invalid bool slice", func(t *testing.T) { + values, err := f.Section("").Key("invalid_feature_list").StrictBools(",") + assert.Empty(t, values) + assert.Error(t, err) + }) + + t.Run("format fallback true bool default", func(t *testing.T) { + key := f.Section("").Key("invalid_feature") + assert.True(t, key.MustBool(true)) + assert.Equal(t, "enabled", key.String()) + }) + + t.Run("format fallback false bool default", func(t *testing.T) { + key := f.Section("").Key("invalid_feature_false") + assert.False(t, key.MustBool(false)) + assert.Equal(t, "disabled", key.String()) + }) + }) + t.Run("allow shadow keys", func(t *testing.T) { f, err := LoadSources(LoadOptions{AllowShadows: true, AllowPythonMultilineValues: true}, []byte(` [remote "origin"]