Skip to content

Commit 29ded9d

Browse files
authored
Automatically format nested objects in YAML files (#1485)
Package Spec v3 doesn't allow to include names with dots in YAMLs, these cases need to be migrated to nested objects. Automate this migration step for this format version.
1 parent 47677f1 commit 29ded9d

File tree

6 files changed

+217
-8
lines changed

6 files changed

+217
-8
lines changed

docs/howto/update_major_package_spec.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ setting was included with and withoud dotted notation.
4242

4343
This is commonly found in `conditions` or in `elasticsearch` settings.
4444

45-
To solve this, please use nested dotations. So if for example your package has
46-
something like the following:
45+
`elastic-package` `check` and `format` subcommands will try to fix this
46+
automatically. If you are still finding this issue, you will need to fix it
47+
manually. For that, please use nested dotations. So if for example your package
48+
has something like the following:
4749
```
4850
conditions:
4951
elastic.subscription: basic

internal/builder/dynamic_mappings.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ func formatResult(result interface{}) ([]byte, error) {
240240
if err != nil {
241241
return nil, errors.New("failed to encode")
242242
}
243-
d, _, err = formatter.YAMLFormatter(d)
243+
yamlFormatter := &formatter.YAMLFormatter{}
244+
d, _, err = yamlFormatter.Format(d)
244245
if err != nil {
245246
return nil, errors.New("failed to format")
246247
}

internal/formatter/formatter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func newFormatter(specVersion semver.Version, ext string) formatter {
2121
case ".json":
2222
return JSONFormatterBuilder(specVersion).Format
2323
case ".yaml", ".yml":
24-
return YAMLFormatter
24+
return NewYAMLFormatter(specVersion).Format
2525
default:
2626
return nil
2727
}

internal/formatter/yaml_formatter.go

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,24 @@ package formatter
77
import (
88
"bytes"
99
"fmt"
10+
"strings"
1011

12+
"github.com/Masterminds/semver/v3"
1113
"gopkg.in/yaml.v3"
1214
)
1315

14-
// YAMLFormatter function is responsible for formatting the given YAML input.
15-
// The function is exposed, so it can be used by other internal packages.
16-
func YAMLFormatter(content []byte) ([]byte, bool, error) {
16+
// YAMLFormatter is responsible for formatting the given YAML input.
17+
type YAMLFormatter struct {
18+
specVersion semver.Version
19+
}
20+
21+
func NewYAMLFormatter(specVersion semver.Version) *YAMLFormatter {
22+
return &YAMLFormatter{
23+
specVersion: specVersion,
24+
}
25+
}
26+
27+
func (f *YAMLFormatter) Format(content []byte) ([]byte, bool, error) {
1728
// yaml.Unmarshal() requires `yaml.Node` to be passed instead of generic `interface{}`.
1829
// Otherwise it can't detect any comments and fields are considered as normal map.
1930
var node yaml.Node
@@ -22,6 +33,10 @@ func YAMLFormatter(content []byte) ([]byte, bool, error) {
2233
return nil, false, fmt.Errorf("unmarshalling YAML file failed: %w", err)
2334
}
2435

36+
if !f.specVersion.LessThan(semver.MustParse("3.0.0")) {
37+
extendNestedObjects(&node)
38+
}
39+
2540
var b bytes.Buffer
2641
encoder := yaml.NewEncoder(&b)
2742
encoder.SetIndent(2)
@@ -39,3 +54,75 @@ func YAMLFormatter(content []byte) ([]byte, bool, error) {
3954

4055
return formatted, string(content) == string(formatted), nil
4156
}
57+
58+
func extendNestedObjects(node *yaml.Node) {
59+
if node.Kind == yaml.MappingNode {
60+
extendMapNode(node)
61+
}
62+
for _, child := range node.Content {
63+
extendNestedObjects(child)
64+
}
65+
}
66+
67+
func extendMapNode(node *yaml.Node) {
68+
for i := 0; i < len(node.Content); i += 2 {
69+
key := node.Content[i]
70+
value := node.Content[i+1]
71+
72+
base, rest, found := strings.Cut(key.Value, ".")
73+
74+
// Insert nested objects only when the key has a dot, and is not quoted.
75+
if found && key.Style == 0 {
76+
// Copy key to create the new parent with the first part of the path.
77+
newKey := *key
78+
newKey.Value = base
79+
newKey.FootComment = ""
80+
newKey.HeadComment = ""
81+
newKey.LineComment = ""
82+
83+
// Copy key also to create the key of the child value.
84+
newChildKey := *key
85+
newChildKey.Value = rest
86+
87+
// Copy the parent node to create the nested object, that contains the new
88+
// child key and the original value.
89+
newNode := *node
90+
newNode.Content = []*yaml.Node{
91+
&newChildKey,
92+
value,
93+
}
94+
95+
// Replace current key and value.
96+
node.Content[i] = &newKey
97+
node.Content[i+1] = &newNode
98+
}
99+
100+
// Recurse on the current value.
101+
extendNestedObjects(node.Content[i+1])
102+
}
103+
104+
mergeNodes(node)
105+
}
106+
107+
// mergeNodes merges the contents of keys with the same name.
108+
func mergeNodes(node *yaml.Node) {
109+
keys := make(map[string]*yaml.Node)
110+
k := 0
111+
for i := 0; i < len(node.Content); i += 2 {
112+
key := node.Content[i]
113+
value := node.Content[i+1]
114+
115+
merged, found := keys[key.Value]
116+
if !found {
117+
keys[key.Value] = value
118+
node.Content[k] = key
119+
node.Content[k+1] = value
120+
k += 2
121+
continue
122+
}
123+
124+
merged.Content = append(merged.Content, value.Content...)
125+
}
126+
127+
node.Content = node.Content[:k]
128+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package formatter
6+
7+
import (
8+
"testing"
9+
10+
"github.com/Masterminds/semver/v3"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestYAMLFormatterNestedObjects(t *testing.T) {
16+
cases := []struct {
17+
title string
18+
doc string
19+
expected string
20+
}{
21+
{
22+
title: "one-level nested setting",
23+
doc: `foo.bar: 3`,
24+
expected: `foo:
25+
bar: 3
26+
`,
27+
},
28+
{
29+
title: "two-level nested setting",
30+
doc: `foo.bar.baz: 3`,
31+
expected: `foo:
32+
bar:
33+
baz: 3
34+
`,
35+
},
36+
{
37+
title: "nested setting at second level",
38+
doc: `foo:
39+
bar.baz: 3`,
40+
expected: `foo:
41+
bar:
42+
baz: 3
43+
`,
44+
},
45+
{
46+
title: "two two-level nested settings",
47+
doc: `foo.bar.baz: 3
48+
a.b.c: 42`,
49+
expected: `foo:
50+
bar:
51+
baz: 3
52+
a:
53+
b:
54+
c: 42
55+
`,
56+
},
57+
{
58+
title: "keep comments with the leaf value",
59+
doc: `foo.bar.baz: 3 # baz
60+
# Mistery of life and everything else.
61+
a.b.c: 42`,
62+
expected: `foo:
63+
bar:
64+
baz: 3 # baz
65+
a:
66+
b:
67+
# Mistery of life and everything else.
68+
c: 42
69+
`,
70+
},
71+
{
72+
title: "keep double-quoted keys",
73+
doc: `"foo.bar.baz": 3`,
74+
expected: "\"foo.bar.baz\": 3\n",
75+
},
76+
{
77+
title: "keep single-quoted keys",
78+
doc: `"foo.bar.baz": 3`,
79+
expected: "\"foo.bar.baz\": 3\n",
80+
},
81+
{
82+
title: "array of maps",
83+
doc: `foo:
84+
- foo.bar: 1
85+
- foo.bar: 2`,
86+
expected: `foo:
87+
- foo:
88+
bar: 1
89+
- foo:
90+
bar: 2
91+
`,
92+
},
93+
{
94+
title: "merge keys",
95+
doc: `es.something: true
96+
es.other.thing: false
97+
es.other.level: 13`,
98+
expected: `es:
99+
something: true
100+
other:
101+
thing: false
102+
level: 13
103+
`,
104+
},
105+
}
106+
107+
sv := semver.MustParse("3.0.0")
108+
formatter := NewYAMLFormatter(*sv).Format
109+
110+
for _, c := range cases {
111+
t.Run(c.title, func(t *testing.T) {
112+
result, _, err := formatter([]byte(c.doc))
113+
require.NoError(t, err)
114+
assert.Equal(t, c.expected, string(result))
115+
})
116+
}
117+
118+
}

internal/packages/changelog/yaml.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ func formatResult(result interface{}) ([]byte, error) {
125125
if err != nil {
126126
return nil, errors.New("failed to encode")
127127
}
128-
d, _, err = formatter.YAMLFormatter(d)
128+
yamlFormatter := &formatter.YAMLFormatter{}
129+
d, _, err = yamlFormatter.Format(d)
129130
if err != nil {
130131
return nil, errors.New("failed to format")
131132
}

0 commit comments

Comments
 (0)