Skip to content

Commit babe1be

Browse files
ostermanclaude
andcommitted
feat: Add YAML key delimiter for dot notation in stack files
Implement configurable key delimiter expansion that transforms dotted YAML keys into nested maps during parsing, bringing stack files in line with atmos.yaml (which already supports dotted keys via Viper). - Add `settings.yaml.key_delimiter` configuration option (opt-in, backwards compatible) - Implement Node-level YAML expansion respecting quoted key preservation - Unquoted keys containing the delimiter expand to nested maps - Quoted keys (single/double) stay literal, enabling escape mechanism - Support custom delimiters (not just `.`, could be `::` or any string) - Mark feature as experimental (gated by `settings.experimental` mode) - Add runtime warning via `ui.Experimental()` when setting is enabled - Add comprehensive unit tests (21 tests covering edge cases) Users want concise dot notation (`metadata.component: vpc-base`) instead of deeply nested YAML. Documentation claimed this worked in stack files but it didn't—Go's yaml.v3 treats dotted keys literally without expansion. This brings feature parity with atmos.yaml's native support via Viper's `deepSearch()` algorithm. Backwards compatible (disabled by default). Feature is marked experimental and respects `settings.experimental` mode (silence/warn/error/disable). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4d0158d commit babe1be

File tree

11 files changed

+753
-3
lines changed

11 files changed

+753
-3
lines changed

cmd/root.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,10 @@ var RootCmd = &cobra.Command{
556556
}
557557
}
558558

559+
// Check for experimental settings (non-command features gated by config values).
560+
// This extends the experimental check above to cover settings like key_delimiter.
561+
checkExperimentalSettings(&tmpConfig)
562+
559563
// Configure lipgloss color profile based on terminal capabilities.
560564
// This ensures tables and styled output degrade gracefully when piped or in non-TTY environments.
561565
term := terminal.New()
@@ -898,6 +902,56 @@ func findExperimentalParent(cmd *cobra.Command) string {
898902
return ""
899903
}
900904

905+
// checkExperimentalSettings checks if any experimental settings are enabled in the config
906+
// and applies the same experimental mode handling (silence/warn/error/disable) as commands.
907+
// This extends the experimental system to cover non-command features gated by config values.
908+
func checkExperimentalSettings(atmosConfig *schema.AtmosConfiguration) {
909+
if atmosConfig == nil {
910+
return
911+
}
912+
913+
// Collect experimental settings that are enabled.
914+
var features []string
915+
if atmosConfig.Settings.YAML.KeyDelimiter != "" {
916+
features = append(features, "settings.yaml.key_delimiter")
917+
}
918+
919+
if len(features) == 0 {
920+
return
921+
}
922+
923+
experimentalMode := atmosConfig.Settings.Experimental
924+
if experimentalMode == "" {
925+
experimentalMode = "warn"
926+
}
927+
928+
for _, feature := range features {
929+
switch experimentalMode {
930+
case "silence":
931+
// Do nothing.
932+
case "disable":
933+
errUtils.CheckErrorPrintAndExit(
934+
errUtils.Build(errUtils.ErrExperimentalDisabled).
935+
WithContext("setting", feature).
936+
WithHint("Enable with settings.experimental: warn").
937+
Err(),
938+
"", "",
939+
)
940+
case "warn":
941+
ui.Experimental(feature)
942+
case "error":
943+
ui.Experimental(feature)
944+
errUtils.CheckErrorPrintAndExit(
945+
errUtils.Build(errUtils.ErrExperimentalRequiresIn).
946+
WithContext("setting", feature).
947+
WithHint("Enable with settings.experimental: warn").
948+
Err(),
949+
"", "",
950+
)
951+
}
952+
}
953+
}
954+
901955
// flagStyles holds the lipgloss styles for flag rendering.
902956
type flagStyles struct {
903957
flagStyle lipgloss.Style

pkg/datafetcher/schema/atmos/manifest/1.0.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,17 @@
613613
},
614614
"templates": {
615615
"$ref": "#/definitions/templates"
616+
},
617+
"yaml": {
618+
"type": "object",
619+
"description": "YAML parsing settings",
620+
"additionalProperties": false,
621+
"properties": {
622+
"key_delimiter": {
623+
"type": "string",
624+
"description": "Delimiter for expanding dotted keys into nested maps (e.g., '.' or '::'). Empty or omitted disables expansion."
625+
}
626+
}
616627
}
617628
},
618629
"required": []

pkg/datafetcher/schema/config/global/1.0.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,17 @@
584584
},
585585
"templates": {
586586
"$ref": "#/definitions/templates"
587+
},
588+
"yaml": {
589+
"type": "object",
590+
"description": "YAML parsing settings",
591+
"additionalProperties": false,
592+
"properties": {
593+
"key_delimiter": {
594+
"type": "string",
595+
"description": "Delimiter for expanding dotted keys into nested maps (e.g., '.' or '::'). Empty or omitted disables expansion."
596+
}
597+
}
587598
}
588599
},
589600
"required": []

pkg/schema/schema.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,20 @@ type SyntaxHighlighting struct {
347347
Wrap bool `yaml:"wrap" json:"wrap" mapstructure:"wrap"`
348348
}
349349

350+
// AtmosYAMLSettings controls YAML parsing behavior for stack files.
351+
type AtmosYAMLSettings struct {
352+
// KeyDelimiter enables nested key expansion in stack YAML files.
353+
// When set (e.g., "."), unquoted keys containing the delimiter are expanded
354+
// into nested map structures (e.g., "a.b: v" becomes "a: {b: v}").
355+
// Quoted keys are preserved as literal keys.
356+
// Empty string (default) disables expansion, preserving current behavior.
357+
KeyDelimiter string `yaml:"key_delimiter" json:"key_delimiter" mapstructure:"key_delimiter"`
358+
}
359+
350360
type AtmosSettings struct {
351-
ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"`
352-
Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"`
361+
ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"`
362+
Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"`
363+
YAML AtmosYAMLSettings `yaml:"yaml,omitempty" json:"yaml,omitempty" mapstructure:"yaml"`
353364
// Experimental controls how experimental features are handled.
354365
// Values: "silence" (no output), "disable" (disabled), "warn" (default), "error" (exit).
355366
Experimental string `yaml:"experimental" json:"experimental" mapstructure:"experimental"`

pkg/utils/yaml_utils.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
log "github.com/cloudposse/atmos/pkg/logger"
1717
"github.com/cloudposse/atmos/pkg/perf"
1818
"github.com/cloudposse/atmos/pkg/schema"
19+
"github.com/cloudposse/atmos/pkg/yaml/expand"
1920
)
2021

2122
const (
@@ -277,6 +278,12 @@ func parseAndCacheYAML(atmosConfig *schema.AtmosConfiguration, input string, fil
277278
positions = ExtractYAMLPositions(&parsedNode, true)
278279
}
279280

281+
// Expand delimited keys (e.g., "a.b: v" -> "a: {b: v}") if configured.
282+
// This runs before custom tag processing so expanded keys are available for tag resolution.
283+
if atmosConfig != nil && atmosConfig.Settings.YAML.KeyDelimiter != "" {
284+
expand.KeyDelimiters(&parsedNode, atmosConfig.Settings.YAML.KeyDelimiter)
285+
}
286+
280287
// Process custom tags.
281288
if err := processCustomTags(atmosConfig, &parsedNode, file); err != nil {
282289
return nil, nil, err

pkg/yaml/expand/expand.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Package expand provides YAML key delimiter expansion for yaml.Node trees.
2+
// It walks mapping nodes and expands unquoted keys containing a configurable
3+
// delimiter into nested map structures, modeled after Viper's deepSearch().
4+
package expand
5+
6+
import (
7+
"strings"
8+
9+
goyaml "gopkg.in/yaml.v3"
10+
11+
"github.com/cloudposse/atmos/pkg/perf"
12+
)
13+
14+
// KeyDelimiters walks a yaml.Node tree and expands unquoted mapping keys
15+
// containing the delimiter into nested mapping structures. Quoted keys
16+
// (single or double) are preserved as literal keys.
17+
//
18+
// For example, with delimiter ".", the unquoted key "metadata.component: vpc-base"
19+
// becomes the nested structure "metadata: { component: vpc-base }".
20+
//
21+
// This is modeled after Viper's deepSearch() approach (viper@v1.21.0/util.go).
22+
func KeyDelimiters(node *goyaml.Node, delimiter string) {
23+
defer perf.Track(nil, "yaml.expand.KeyDelimiters")()
24+
25+
if node == nil || delimiter == "" {
26+
return
27+
}
28+
29+
keyDelimitersRecursive(node, delimiter)
30+
}
31+
32+
// keyDelimitersRecursive is the recursive implementation.
33+
// Separated from the public entry point so perf.Track fires only once.
34+
func keyDelimitersRecursive(node *goyaml.Node, delimiter string) {
35+
if node == nil {
36+
return
37+
}
38+
39+
switch node.Kind {
40+
case goyaml.DocumentNode:
41+
for _, child := range node.Content {
42+
keyDelimitersRecursive(child, delimiter)
43+
}
44+
case goyaml.MappingNode:
45+
expandMappingKeys(node, delimiter)
46+
case goyaml.SequenceNode:
47+
for _, child := range node.Content {
48+
keyDelimitersRecursive(child, delimiter)
49+
}
50+
}
51+
}
52+
53+
// expandMappingKeys processes a single MappingNode, expanding unquoted delimited keys
54+
// into nested structures.
55+
func expandMappingKeys(node *goyaml.Node, delimiter string) {
56+
// First, recurse into all value nodes so nested maps are expanded bottom-up.
57+
for i := 1; i < len(node.Content); i += 2 {
58+
keyDelimitersRecursive(node.Content[i], delimiter)
59+
}
60+
61+
// Collect expanded entries: for each expandable key, build nested nodes.
62+
// Non-expandable keys pass through unchanged.
63+
var newContent []*goyaml.Node
64+
65+
for i := 0; i < len(node.Content); i += 2 {
66+
keyNode := node.Content[i]
67+
valueNode := node.Content[i+1]
68+
69+
if shouldExpand(keyNode, delimiter) {
70+
parts := strings.Split(keyNode.Value, delimiter)
71+
nested := buildNestedNodes(parts, valueNode)
72+
// Merge the expanded key-value pair into newContent.
73+
mergeIntoContent(&newContent, nested[0], nested[1])
74+
} else {
75+
// Non-expandable: merge as-is (handles duplicate keys by last-wins).
76+
mergeIntoContent(&newContent, keyNode, valueNode)
77+
}
78+
}
79+
80+
node.Content = newContent
81+
}
82+
83+
// shouldExpand returns true if the key node should be expanded:
84+
// - Must be a scalar node.
85+
// - Must be unquoted (Style == 0).
86+
// - Must contain the delimiter.
87+
// - Must not have leading, trailing, or consecutive delimiters.
88+
func shouldExpand(keyNode *goyaml.Node, delimiter string) bool {
89+
if keyNode.Kind != goyaml.ScalarNode {
90+
return false
91+
}
92+
93+
// Quoted keys are never expanded.
94+
if keyNode.Style == goyaml.DoubleQuotedStyle || keyNode.Style == goyaml.SingleQuotedStyle {
95+
return false
96+
}
97+
98+
value := keyNode.Value
99+
if !strings.Contains(value, delimiter) {
100+
return false
101+
}
102+
103+
// Reject malformed patterns: leading, trailing, or consecutive delimiters.
104+
if strings.HasPrefix(value, delimiter) || strings.HasSuffix(value, delimiter) {
105+
return false
106+
}
107+
if strings.Contains(value, delimiter+delimiter) {
108+
return false
109+
}
110+
111+
// All parts must be non-empty after splitting.
112+
parts := strings.Split(value, delimiter)
113+
for _, part := range parts {
114+
if part == "" {
115+
return false
116+
}
117+
}
118+
119+
return true
120+
}
121+
122+
// buildNestedNodes creates a chain of nested MappingNodes from the key parts.
123+
// For parts ["a", "b", "c"] and a value node, it creates:
124+
//
125+
// a: { b: { c: value } }
126+
//
127+
// Returns [keyNode, valueNode] where valueNode may be a nested MappingNode.
128+
func buildNestedNodes(parts []string, valueNode *goyaml.Node) [2]*goyaml.Node {
129+
// Build from innermost to outermost.
130+
currentValue := valueNode
131+
for i := len(parts) - 1; i >= 1; i-- {
132+
innerKey := &goyaml.Node{
133+
Kind: goyaml.ScalarNode,
134+
Tag: "!!str",
135+
Value: parts[i],
136+
}
137+
innerMap := &goyaml.Node{
138+
Kind: goyaml.MappingNode,
139+
Tag: "!!map",
140+
Content: []*goyaml.Node{innerKey, currentValue},
141+
}
142+
currentValue = innerMap
143+
}
144+
145+
outerKey := &goyaml.Node{
146+
Kind: goyaml.ScalarNode,
147+
Tag: "!!str",
148+
Value: parts[0],
149+
}
150+
151+
return [2]*goyaml.Node{outerKey, currentValue}
152+
}
153+
154+
// mergeIntoContent adds a key-value pair to the content slice.
155+
// If the key already exists and both old and new values are MappingNodes,
156+
// the entries are merged (new entries win on conflict).
157+
// Otherwise, the old entry is replaced (last-wins semantics).
158+
func mergeIntoContent(content *[]*goyaml.Node, keyNode, valueNode *goyaml.Node) {
159+
// Look for an existing key with the same value.
160+
for i := 0; i < len(*content); i += 2 {
161+
existingKey := (*content)[i]
162+
if existingKey.Kind == goyaml.ScalarNode && existingKey.Value == keyNode.Value {
163+
existingValue := (*content)[i+1]
164+
165+
// If both are mappings, merge the entries.
166+
if existingValue.Kind == goyaml.MappingNode && valueNode.Kind == goyaml.MappingNode {
167+
mergeMappingNodes(existingValue, valueNode)
168+
return
169+
}
170+
171+
// Otherwise, replace (last-wins).
172+
(*content)[i+1] = valueNode
173+
return
174+
}
175+
}
176+
177+
// Key not found: append.
178+
*content = append(*content, keyNode, valueNode)
179+
}
180+
181+
// mergeMappingNodes merges entries from src into dst.
182+
// If a key exists in both and both values are MappingNodes, they are recursively merged.
183+
// Otherwise, src wins (last-wins).
184+
func mergeMappingNodes(dst, src *goyaml.Node) {
185+
for i := 0; i < len(src.Content); i += 2 {
186+
srcKey := src.Content[i]
187+
srcValue := src.Content[i+1]
188+
mergeIntoContent(&dst.Content, srcKey, srcValue)
189+
}
190+
}

0 commit comments

Comments
 (0)