From 771cced8427013d305dbaea5c2f68e7e4a658850 Mon Sep 17 00:00:00 2001 From: maxBRT Date: Mon, 13 Oct 2025 19:45:38 -0400 Subject: [PATCH 1/8] Bug fix: Key delimiters are now case insensitive --- viper.go | 4 +++- viper_test.go | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/viper.go b/viper.go index cbea96282..37974c6b4 100644 --- a/viper.go +++ b/viper.go @@ -1464,10 +1464,12 @@ func Set(key string, value any) { v.Set(key, value) } func (v *Viper) Set(key string, value any) { // If alias passed in, then set the proper override + key = v.realKey(strings.ToLower(key)) value = toCaseInsensitiveValue(value) - path := strings.Split(key, v.keyDelim) + path := strings.Split(key, strings.ToLower(v.keyDelim)) + lastKey := strings.ToLower(path[len(path)-1]) deepestMap := deepSearch(v.override, path[0:len(path)-1]) diff --git a/viper_test.go b/viper_test.go index 8b0232aee..6887b4b60 100644 --- a/viper_test.go +++ b/viper_test.go @@ -2718,3 +2718,14 @@ func skipWindows(t *testing.T) { t.Skip("Skip test on Windows") } } + +// Test the Set method with key delimiter for case insenitivty +func TestUpperCaseKeyDelimiter(t *testing.T) { + v := NewWithOptions(KeyDelimiter("Z")) + v.Set("fooZbar", "Foo Bar Baz") + + got := v.Get("foo") + want := map[string]any{"bar": "Foo Bar Baz"} + + assert.Equal(t, want, got) +} From 7de42545181ddc051c7463cacb800b76ab266422 Mon Sep 17 00:00:00 2001 From: maxBRT Date: Wed, 15 Oct 2025 13:37:56 -0400 Subject: [PATCH 2/8] address review feedback --- viper.go | 25 ++++---- viper_test.go | 159 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 128 insertions(+), 56 deletions(-) diff --git a/viper.go b/viper.go index 37974c6b4..834cf4d48 100644 --- a/viper.go +++ b/viper.go @@ -486,7 +486,6 @@ func (v *Viper) searchIndexableWithPathPrefixes(source any, path []string) any { // search for path prefixes, starting from the longest one for i := len(path); i > 0; i-- { prefixKey := strings.ToLower(strings.Join(path[0:i], v.keyDelim)) - var val any switch sourceIndexable := source.(type) { case []any: @@ -593,7 +592,7 @@ func (v *Viper) isPathShadowedInDeepMap(path []string, m map[string]any) string continue default: // parentVal is a regular value which shadows "path" - return strings.Join(path[0:i], v.keyDelim) + return strings.Join(path[0:i], strings.ToLower(v.keyDelim)) } } return "" @@ -619,7 +618,7 @@ func (v *Viper) isPathShadowedInFlatMap(path []string, mi any) string { // scan paths var parentKey string for i := 1; i < len(path); i++ { - parentKey = strings.Join(path[0:i], v.keyDelim) + parentKey = strings.Join(path[0:i], strings.ToLower(v.keyDelim)) if _, ok := m[parentKey]; ok { return parentKey } @@ -635,7 +634,7 @@ func (v *Viper) isPathShadowedInFlatMap(path []string, mi any) string { func (v *Viper) isPathShadowedInAutoEnv(path []string) string { var parentKey string for i := 1; i < len(path); i++ { - parentKey = strings.Join(path[0:i], v.keyDelim) + parentKey = strings.Join(path[0:i], strings.ToLower(v.keyDelim)) if _, ok := v.getEnv(v.mergeWithEnvPrefix(parentKey)); ok { return parentKey } @@ -687,7 +686,7 @@ func (v *Viper) Get(key string) any { if v.typeByDefValue { // TODO(bep) this branch isn't covered by a single test. valType := val - path := strings.Split(lcaseKey, v.keyDelim) + path := strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) defVal := v.searchMap(v.defaults, path) if defVal != nil { valType = defVal @@ -1114,7 +1113,7 @@ func (v *Viper) find(lcaseKey string, flagDefault bool) any { var ( val any exists bool - path = strings.Split(lcaseKey, v.keyDelim) + path = strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) nested = len(path) > 1 ) @@ -1125,7 +1124,7 @@ func (v *Viper) find(lcaseKey string, flagDefault bool) any { // if the requested key is an alias, then return the proper key lcaseKey = v.realKey(lcaseKey) - path = strings.Split(lcaseKey, v.keyDelim) + path = strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) nested = len(path) > 1 // Set() override first @@ -1433,7 +1432,7 @@ func (v *Viper) InConfig(key string) bool { // if the requested key is an alias, then return the proper key lcaseKey = v.realKey(lcaseKey) - path := strings.Split(lcaseKey, v.keyDelim) + path := strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) return v.searchIndexableWithPathPrefixes(v.config, path) != nil } @@ -1448,7 +1447,7 @@ func (v *Viper) SetDefault(key string, value any) { key = v.realKey(strings.ToLower(key)) value = toCaseInsensitiveValue(value) - path := strings.Split(key, v.keyDelim) + path := strings.Split(key, strings.ToLower(v.keyDelim)) lastKey := strings.ToLower(path[len(path)-1]) deepestMap := deepSearch(v.defaults, path[0:len(path)-1]) @@ -1919,11 +1918,13 @@ func (v *Viper) mergeFlatMap(shadow map[string]bool, m map[string]any) map[strin // scan keys outer: for k := range m { - path := strings.Split(k, v.keyDelim) + fmt.Println(k) + path := strings.Split(k, strings.ToLower(v.keyDelim)) + fmt.Println(path) // scan intermediate paths var parentKey string for i := 1; i < len(path); i++ { - parentKey = strings.Join(path[0:i], v.keyDelim) + parentKey = strings.Join(path[0:i], strings.ToLower(v.keyDelim)) if shadow[parentKey] { // path is shadowed, continue continue outer @@ -1952,7 +1953,7 @@ func (v *Viper) getSettings(keys []string) map[string]any { // check just in case anything changes continue } - path := strings.Split(k, v.keyDelim) + path := strings.Split(k, strings.ToLower(v.keyDelim)) lastKey := strings.ToLower(path[len(path)-1]) deepestMap := deepSearch(m, path[0:len(path)-1]) // set innermost value diff --git a/viper_test.go b/viper_test.go index 6887b4b60..e14d54372 100644 --- a/viper_test.go +++ b/viper_test.go @@ -2533,50 +2533,132 @@ func TestUnmarshal_DotSeparatorBackwardCompatibility(t *testing.T) { // `) func TestKeyDelimiter(t *testing.T) { - v := NewWithOptions(KeyDelimiter("::")) - v.SetConfigType("yaml") - r := strings.NewReader(string(yamlExampleWithDot)) + t.Run("KeyDelimiterYAMLAndUnmarshal", func(t *testing.T) { + v := NewWithOptions(KeyDelimiter("::")) + v.SetConfigType("yaml") + r := strings.NewReader(string(yamlExampleWithDot)) - err := v.unmarshalReader(r, v.config) - require.NoError(t, err) + err := v.unmarshalReader(r, v.config) + require.NoError(t, err) - values := map[string]any{ - "image": map[string]any{ - "repository": "someImage", - "tag": "1.0.0", - }, - "ingress": map[string]any{ - "annotations": map[string]any{ - "traefik.frontend.rule.type": "PathPrefix", - "traefik.ingress.kubernetes.io/ssl-redirect": "true", + values := map[string]any{ + "image": map[string]any{ + "repository": "someImage", + "tag": "1.0.0", }, - }, - } + "ingress": map[string]any{ + "annotations": map[string]any{ + "traefik.frontend.rule.type": "PathPrefix", + "traefik.ingress.kubernetes.io/ssl-redirect": "true", + }, + }, + } - v.SetDefault("charts::values", values) + v.SetDefault("charts::values", values) - assert.Equal(t, "leather", v.GetString("clothing::jacket")) - assert.Equal(t, "01/02/03", v.GetString("emails::steve@hacker.com::created")) + assert.Equal(t, "leather", v.GetString("clothing::jacket")) + assert.Equal(t, "01/02/03", v.GetString("emails::steve@hacker.com::created")) - type config struct { - Charts struct { - Values map[string]any + type config struct { + Charts struct { + Values map[string]any + } } - } - expected := config{ - Charts: struct { - Values map[string]any - }{ - Values: values, - }, - } + expected := config{ + Charts: struct { + Values map[string]any + }{ + Values: values, + }, + } - var actual config + var actual config - require.NoError(t, v.Unmarshal(&actual)) + require.NoError(t, v.Unmarshal(&actual)) - assert.Equal(t, expected, actual) + assert.Equal(t, expected, actual) + }) + + // Test the Set method with key delimiter for case insenitivty + t.Run("CaseInsensitiveDelimiter", func(t *testing.T) { + v := NewWithOptions(KeyDelimiter("Z")) + v.Set("fooZbar", "Foo Bar Baz") + + got := v.Get("foo") + want := map[string]any{"bar": "Foo Bar Baz"} + assert.Equal(t, want, got) + + v.Set("foozbar", "Foo Bar Baz") + got = v.Get("foo") + assert.Equal(t, want, got) + + v.Set("baz", "Bazzz") + got = v.Get("baz") + want2 := "Bazzz" + assert.Equal(t, want2, got) + }) + + // Test the InConfig method with key delimiter for case insenitivty + t.Run("UpperCasedDelimInConfig", func(t *testing.T) { + v := NewWithOptions(KeyDelimiter("Z")) + + v.config = map[string]any{ + "foo": map[string]any{ + "bar": "nestedValue", + }, + } + assert.True(t, v.InConfig("fooZbar")) + assert.True(t, v.InConfig("foozbar")) + }) + + // Test the SetDefault method with key delimiter for case insenitivty + t.Run("UpperCasedDelimSetDefault", func(t *testing.T) { + v := NewWithOptions(KeyDelimiter("Z")) + v.SetDefault("fooZbar", "Foo Bar Baz") + + got := v.Get("foo") + want := map[string]any{"bar": "Foo Bar Baz"} + assert.Equal(t, want, got) + + v.SetDefault("foozbar", "Foo Bar Baz") + got = v.Get("foo") + assert.Equal(t, want, got) + }) + + // Test the flattenAndMergeMap private method with key delimiter for case insenitivty + t.Run("UpperCasedDelimFlattenAndMerge", func(t *testing.T) { + config := map[string]any{ + "foo": map[string]any{ + "bar": 123, + "baz": 456, + }, + } + + v := NewWithOptions(KeyDelimiter("Z")) + shadow := make(map[string]bool) + shadow = v.flattenAndMergeMap(shadow, config, "") + + assert.True(t, shadow["foozbar"]) + assert.True(t, shadow["foozbaz"]) + }) + + // Test the AllSettings method with key delimiter for case insenitivty + t.Run("UpperCasedDelimAllSettings", func(t *testing.T) { + v := NewWithOptions(KeyDelimiter("Z")) + + v.Set("foozbar", 123) + + got := v.AllSettings() + + want := map[string]any{ + "foo": map[string]any{ + "bar": 123, + }, + } + + assert.Equal(t, want, got) + }) } var yamlDeepNestedSlices = []byte(`TV: @@ -2718,14 +2800,3 @@ func skipWindows(t *testing.T) { t.Skip("Skip test on Windows") } } - -// Test the Set method with key delimiter for case insenitivty -func TestUpperCaseKeyDelimiter(t *testing.T) { - v := NewWithOptions(KeyDelimiter("Z")) - v.Set("fooZbar", "Foo Bar Baz") - - got := v.Get("foo") - want := map[string]any{"bar": "Foo Bar Baz"} - - assert.Equal(t, want, got) -} From 275cdff4e83bb7575fc64b4e25cb8b12818bef11 Mon Sep 17 00:00:00 2001 From: maxBRT Date: Sun, 26 Oct 2025 10:46:26 -0400 Subject: [PATCH 3/8] Lowering keyDelim at definition --- viper.go | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/viper.go b/viper.go index 834cf4d48..bb738bf98 100644 --- a/viper.go +++ b/viper.go @@ -201,7 +201,7 @@ func (fn optionFunc) apply(v *Viper) { // By default it's value is ".". func KeyDelimiter(d string) Option { return optionFunc(func(v *Viper) { - v.keyDelim = d + v.keyDelim = strings.ToLower(d) }) } @@ -592,7 +592,7 @@ func (v *Viper) isPathShadowedInDeepMap(path []string, m map[string]any) string continue default: // parentVal is a regular value which shadows "path" - return strings.Join(path[0:i], strings.ToLower(v.keyDelim)) + return strings.Join(path[0:i], v.keyDelim) } } return "" @@ -618,7 +618,7 @@ func (v *Viper) isPathShadowedInFlatMap(path []string, mi any) string { // scan paths var parentKey string for i := 1; i < len(path); i++ { - parentKey = strings.Join(path[0:i], strings.ToLower(v.keyDelim)) + parentKey = strings.Join(path[0:i], v.keyDelim) if _, ok := m[parentKey]; ok { return parentKey } @@ -634,7 +634,7 @@ func (v *Viper) isPathShadowedInFlatMap(path []string, mi any) string { func (v *Viper) isPathShadowedInAutoEnv(path []string) string { var parentKey string for i := 1; i < len(path); i++ { - parentKey = strings.Join(path[0:i], strings.ToLower(v.keyDelim)) + parentKey = strings.Join(path[0:i], v.keyDelim) if _, ok := v.getEnv(v.mergeWithEnvPrefix(parentKey)); ok { return parentKey } @@ -686,7 +686,7 @@ func (v *Viper) Get(key string) any { if v.typeByDefValue { // TODO(bep) this branch isn't covered by a single test. valType := val - path := strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) + path := strings.Split(lcaseKey, v.keyDelim) defVal := v.searchMap(v.defaults, path) if defVal != nil { valType = defVal @@ -1113,7 +1113,7 @@ func (v *Viper) find(lcaseKey string, flagDefault bool) any { var ( val any exists bool - path = strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) + path = strings.Split(lcaseKey, v.keyDelim) nested = len(path) > 1 ) @@ -1124,7 +1124,7 @@ func (v *Viper) find(lcaseKey string, flagDefault bool) any { // if the requested key is an alias, then return the proper key lcaseKey = v.realKey(lcaseKey) - path = strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) + path = strings.Split(lcaseKey, v.keyDelim) nested = len(path) > 1 // Set() override first @@ -1432,7 +1432,7 @@ func (v *Viper) InConfig(key string) bool { // if the requested key is an alias, then return the proper key lcaseKey = v.realKey(lcaseKey) - path := strings.Split(lcaseKey, strings.ToLower(v.keyDelim)) + path := strings.Split(lcaseKey, v.keyDelim) return v.searchIndexableWithPathPrefixes(v.config, path) != nil } @@ -1447,7 +1447,7 @@ func (v *Viper) SetDefault(key string, value any) { key = v.realKey(strings.ToLower(key)) value = toCaseInsensitiveValue(value) - path := strings.Split(key, strings.ToLower(v.keyDelim)) + path := strings.Split(key, v.keyDelim) lastKey := strings.ToLower(path[len(path)-1]) deepestMap := deepSearch(v.defaults, path[0:len(path)-1]) @@ -1467,7 +1467,7 @@ func (v *Viper) Set(key string, value any) { key = v.realKey(strings.ToLower(key)) value = toCaseInsensitiveValue(value) - path := strings.Split(key, strings.ToLower(v.keyDelim)) + path := strings.Split(key, v.keyDelim) lastKey := strings.ToLower(path[len(path)-1]) deepestMap := deepSearch(v.override, path[0:len(path)-1]) @@ -1918,13 +1918,11 @@ func (v *Viper) mergeFlatMap(shadow map[string]bool, m map[string]any) map[strin // scan keys outer: for k := range m { - fmt.Println(k) - path := strings.Split(k, strings.ToLower(v.keyDelim)) - fmt.Println(path) + path := strings.Split(k, v.keyDelim) // scan intermediate paths var parentKey string for i := 1; i < len(path); i++ { - parentKey = strings.Join(path[0:i], strings.ToLower(v.keyDelim)) + parentKey = strings.Join(path[0:i], v.keyDelim) if shadow[parentKey] { // path is shadowed, continue continue outer @@ -1953,7 +1951,7 @@ func (v *Viper) getSettings(keys []string) map[string]any { // check just in case anything changes continue } - path := strings.Split(k, strings.ToLower(v.keyDelim)) + path := strings.Split(k, v.keyDelim) lastKey := strings.ToLower(path[len(path)-1]) deepestMap := deepSearch(m, path[0:len(path)-1]) // set innermost value From ba74578a75503bf833eee19ba3a6c1d275f50a62 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Sun, 2 Nov 2025 06:58:00 -0500 Subject: [PATCH 4/8] Update viper_test.go Co-authored-by: Matthew Coleman --- viper_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/viper_test.go b/viper_test.go index e14d54372..86abb673c 100644 --- a/viper_test.go +++ b/viper_test.go @@ -2556,6 +2556,7 @@ func TestKeyDelimiter(t *testing.T) { v.SetDefault("charts::values", values) + // It retrieves existing values using the custom delimeter assert.Equal(t, "leather", v.GetString("clothing::jacket")) assert.Equal(t, "01/02/03", v.GetString("emails::steve@hacker.com::created")) From bc08317c9a5aa5fd59d0ac3c0c5268fd401bf950 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Sun, 2 Nov 2025 06:59:21 -0500 Subject: [PATCH 5/8] Update viper_test.go Co-authored-by: Matthew Coleman --- viper_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/viper_test.go b/viper_test.go index 86abb673c..97d3a8066 100644 --- a/viper_test.go +++ b/viper_test.go @@ -2578,6 +2578,7 @@ func TestKeyDelimiter(t *testing.T) { require.NoError(t, v.Unmarshal(&actual)) + // It sets and unmarshals new values with the custom delimiter assert.Equal(t, expected, actual) }) From f26505b81586c88289786bef80cf1e14eef14ed1 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Sun, 2 Nov 2025 06:59:31 -0500 Subject: [PATCH 6/8] Update viper_test.go Co-authored-by: Matthew Coleman --- viper_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viper_test.go b/viper_test.go index 97d3a8066..b8e768a4a 100644 --- a/viper_test.go +++ b/viper_test.go @@ -2641,7 +2641,7 @@ func TestKeyDelimiter(t *testing.T) { shadow := make(map[string]bool) shadow = v.flattenAndMergeMap(shadow, config, "") - assert.True(t, shadow["foozbar"]) + assert.True(t, shadow["fooZbar"]) assert.True(t, shadow["foozbaz"]) }) From 240d9b28867105e1e21cb8837b87d016456cbd88 Mon Sep 17 00:00:00 2001 From: maxBRT Date: Sun, 2 Nov 2025 07:07:29 -0500 Subject: [PATCH 7/8] Added comment to teste case --- viper_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/viper_test.go b/viper_test.go index b8e768a4a..d7592bd3c 100644 --- a/viper_test.go +++ b/viper_test.go @@ -2595,6 +2595,7 @@ func TestKeyDelimiter(t *testing.T) { got = v.Get("foo") assert.Equal(t, want, got) + // Verify that setting a key ending with the delimiter does not create an empty key after the final character. v.Set("baz", "Bazzz") got = v.Get("baz") want2 := "Bazzz" From a0f82e145d8de8a3794d16d98ec7f5681ecefc50 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Sun, 2 Nov 2025 07:13:03 -0500 Subject: [PATCH 8/8] Add note about case-insensitivity of delimiter Clarify that the delimiter is case-insensitive for alphabetic characters. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f48ebb790..f77bc67ac 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,7 @@ if err != nil { If you want to unmarshal configuration where the keys themselves contain `.` (the default key delimiter), you can change the delimiter. +The delimiter is case-insensitive when using alphabetic characters. ```go v := viper.NewWithOptions(viper.KeyDelimiter("::"))