Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,10 @@ var RootCmd = &cobra.Command{
}
}

// Check for experimental settings (non-command features gated by config values).
// This extends the experimental check above to cover settings like key_delimiter.
checkExperimentalSettings(&tmpConfig)

// Configure lipgloss color profile based on terminal capabilities.
// This ensures tables and styled output degrade gracefully when piped or in non-TTY environments.
term := terminal.New()
Expand Down Expand Up @@ -898,6 +902,56 @@ func findExperimentalParent(cmd *cobra.Command) string {
return ""
}

// checkExperimentalSettings checks if any experimental settings are enabled in the config
// and applies the same experimental mode handling (silence/warn/error/disable) as commands.
// This extends the experimental system to cover non-command features gated by config values.
func checkExperimentalSettings(atmosConfig *schema.AtmosConfiguration) {
if atmosConfig == nil {
return
}

// Collect experimental settings that are enabled.
var features []string
if atmosConfig.Settings.YAML.KeyDelimiter != "" {
features = append(features, "settings.yaml.key_delimiter")
}

if len(features) == 0 {
return
}

experimentalMode := atmosConfig.Settings.Experimental
if experimentalMode == "" {
experimentalMode = "warn"
}

for _, feature := range features {
switch experimentalMode {
case "silence":
// Do nothing.
case "disable":
errUtils.CheckErrorPrintAndExit(
errUtils.Build(errUtils.ErrExperimentalDisabled).
WithContext("setting", feature).
WithHint("Enable with settings.experimental: warn").
Err(),
"", "",
)
case "warn":
ui.Experimental(feature)
case "error":
ui.Experimental(feature)
errUtils.CheckErrorPrintAndExit(
errUtils.Build(errUtils.ErrExperimentalRequiresIn).
WithContext("setting", feature).
WithHint("Enable with settings.experimental: warn").
Err(),
"", "",
)
}
}
}

// flagStyles holds the lipgloss styles for flag rendering.
type flagStyles struct {
flagStyle lipgloss.Style
Expand Down
11 changes: 11 additions & 0 deletions pkg/datafetcher/schema/atmos/manifest/1.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,17 @@
},
"templates": {
"$ref": "#/definitions/templates"
},
"yaml": {
"type": "object",
"description": "YAML parsing settings",
"additionalProperties": false,
"properties": {
"key_delimiter": {
"type": "string",
"description": "Delimiter for expanding dotted keys into nested maps (e.g., '.' or '::'). Empty or omitted disables expansion."
}
}
}
},
"required": []
Expand Down
11 changes: 11 additions & 0 deletions pkg/datafetcher/schema/config/global/1.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,17 @@
},
"templates": {
"$ref": "#/definitions/templates"
},
"yaml": {
"type": "object",
"description": "YAML parsing settings",
"additionalProperties": false,
"properties": {
"key_delimiter": {
"type": "string",
"description": "Delimiter for expanding dotted keys into nested maps (e.g., '.' or '::'). Empty or omitted disables expansion."
}
}
}
},
"required": []
Expand Down
15 changes: 13 additions & 2 deletions pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,20 @@ type SyntaxHighlighting struct {
Wrap bool `yaml:"wrap" json:"wrap" mapstructure:"wrap"`
}

// AtmosYAMLSettings controls YAML parsing behavior for stack files.
type AtmosYAMLSettings struct {
// KeyDelimiter enables nested key expansion in stack YAML files.
// When set (e.g., "."), unquoted keys containing the delimiter are expanded
// into nested map structures (e.g., "a.b: v" becomes "a: {b: v}").
// Quoted keys are preserved as literal keys.
// Empty string (default) disables expansion, preserving current behavior.
KeyDelimiter string `yaml:"key_delimiter" json:"key_delimiter" mapstructure:"key_delimiter"`
}

type AtmosSettings struct {
ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"`
Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"`
ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"`
Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"`
YAML AtmosYAMLSettings `yaml:"yaml,omitempty" json:"yaml,omitempty" mapstructure:"yaml"`
// Experimental controls how experimental features are handled.
// Values: "silence" (no output), "disable" (disabled), "warn" (default), "error" (exit).
Experimental string `yaml:"experimental" json:"experimental" mapstructure:"experimental"`
Expand Down
7 changes: 7 additions & 0 deletions pkg/utils/yaml_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/yaml/expand"
)

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

// Expand delimited keys (e.g., "a.b: v" -> "a: {b: v}") if configured.
// This runs before custom tag processing so expanded keys are available for tag resolution.
if atmosConfig != nil && atmosConfig.Settings.YAML.KeyDelimiter != "" {
expand.KeyDelimiters(&parsedNode, atmosConfig.Settings.YAML.KeyDelimiter)
}
Comment on lines +281 to +285
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache key must include delimiter-dependent parse behavior.

Line 283 makes parse output depend on settings.yaml.key_delimiter, but cached entries are still keyed by file+content only. This can return incorrect cached trees when delimiter differs across loads in the same process.

Suggested direction
-func generateParsedYAMLCacheKey(file string, content string) string {
+func generateParsedYAMLCacheKey(file string, content string, keyDelimiter string) string {
@@
-	return file + ":" + contentHash
+	return file + ":" + contentHash + ":" + keyDelimiter
}
-func getCachedParsedYAML(file string, content string) (*yaml.Node, PositionMap, bool) {
-	cacheKey := generateParsedYAMLCacheKey(file, content)
+func getCachedParsedYAML(file string, content string, keyDelimiter string) (*yaml.Node, PositionMap, bool) {
+	cacheKey := generateParsedYAMLCacheKey(file, content, keyDelimiter)
-func cacheParsedYAML(file string, content string, node *yaml.Node, positions PositionMap) {
-	cacheKey := generateParsedYAMLCacheKey(file, content)
+func cacheParsedYAML(file string, content string, keyDelimiter string, node *yaml.Node, positions PositionMap) {
+	cacheKey := generateParsedYAMLCacheKey(file, content, keyDelimiter)
+	keyDelimiter := ""
+	if atmosConfig != nil {
+		keyDelimiter = atmosConfig.Settings.YAML.KeyDelimiter
+	}
@@
-	cacheParsedYAML(file, input, &parsedNode, positions)
+	cacheParsedYAML(file, input, keyDelimiter, &parsedNode, positions)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/utils/yaml_utils.go` around lines 281 - 285, The YAML parse cache must
include the delimiter so parse results reflect delimiter-dependent expansion;
update the cache key generation (the code that caches the parsed tree for
parsedNode) to incorporate atmosConfig.Settings.YAML.KeyDelimiter (or an
explicit delimiter value) along with the file and content fingerprint, and
ensure expand.KeyDelimiters(&parsedNode, atmosConfig.Settings.YAML.KeyDelimiter)
still runs after parsing; this makes cache lookups consider the delimiter and
prevents returning a tree expanded with a different key_delimiter.


// Process custom tags.
if err := processCustomTags(atmosConfig, &parsedNode, file); err != nil {
return nil, nil, err
Expand Down
190 changes: 190 additions & 0 deletions pkg/yaml/expand/expand.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Package expand provides YAML key delimiter expansion for yaml.Node trees.
// It walks mapping nodes and expands unquoted keys containing a configurable
// delimiter into nested map structures, modeled after Viper's deepSearch().
package expand

import (
"strings"

goyaml "gopkg.in/yaml.v3"

"github.com/cloudposse/atmos/pkg/perf"
)

// KeyDelimiters walks a yaml.Node tree and expands unquoted mapping keys
// containing the delimiter into nested mapping structures. Quoted keys
// (single or double) are preserved as literal keys.
//
// For example, with delimiter ".", the unquoted key "metadata.component: vpc-base"
// becomes the nested structure "metadata: { component: vpc-base }".
//
// This is modeled after Viper's deepSearch() approach (viper@v1.21.0/util.go).
func KeyDelimiters(node *goyaml.Node, delimiter string) {
defer perf.Track(nil, "yaml.expand.KeyDelimiters")()

if node == nil || delimiter == "" {
return
}

keyDelimitersRecursive(node, delimiter)
}

// keyDelimitersRecursive is the recursive implementation.
// Separated from the public entry point so perf.Track fires only once.
func keyDelimitersRecursive(node *goyaml.Node, delimiter string) {
if node == nil {
return
}

switch node.Kind {
case goyaml.DocumentNode:
for _, child := range node.Content {
keyDelimitersRecursive(child, delimiter)
}
case goyaml.MappingNode:
expandMappingKeys(node, delimiter)
case goyaml.SequenceNode:
for _, child := range node.Content {
keyDelimitersRecursive(child, delimiter)
}
}
}

// expandMappingKeys processes a single MappingNode, expanding unquoted delimited keys
// into nested structures.
func expandMappingKeys(node *goyaml.Node, delimiter string) {
// First, recurse into all value nodes so nested maps are expanded bottom-up.
for i := 1; i < len(node.Content); i += 2 {
keyDelimitersRecursive(node.Content[i], delimiter)
}

// Collect expanded entries: for each expandable key, build nested nodes.
// Non-expandable keys pass through unchanged.
var newContent []*goyaml.Node

for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]

if shouldExpand(keyNode, delimiter) {
parts := strings.Split(keyNode.Value, delimiter)
nested := buildNestedNodes(parts, valueNode)
// Merge the expanded key-value pair into newContent.
mergeIntoContent(&newContent, nested[0], nested[1])
} else {
// Non-expandable: merge as-is (handles duplicate keys by last-wins).
mergeIntoContent(&newContent, keyNode, valueNode)
}
}

node.Content = newContent
}

// shouldExpand returns true if the key node should be expanded:
// - Must be a scalar node.
// - Must be unquoted (Style == 0).
// - Must contain the delimiter.
// - Must not have leading, trailing, or consecutive delimiters.
func shouldExpand(keyNode *goyaml.Node, delimiter string) bool {
if keyNode.Kind != goyaml.ScalarNode {
return false
}

// Quoted keys are never expanded.
if keyNode.Style == goyaml.DoubleQuotedStyle || keyNode.Style == goyaml.SingleQuotedStyle {
return false
}

value := keyNode.Value
if !strings.Contains(value, delimiter) {
return false
}

// Reject malformed patterns: leading, trailing, or consecutive delimiters.
if strings.HasPrefix(value, delimiter) || strings.HasSuffix(value, delimiter) {
return false
}
if strings.Contains(value, delimiter+delimiter) {
return false
}

// All parts must be non-empty after splitting.
parts := strings.Split(value, delimiter)
for _, part := range parts {
if part == "" {
return false
}
}

return true
}

// buildNestedNodes creates a chain of nested MappingNodes from the key parts.
// For parts ["a", "b", "c"] and a value node, it creates:
//
// a: { b: { c: value } }
//
// Returns [keyNode, valueNode] where valueNode may be a nested MappingNode.
func buildNestedNodes(parts []string, valueNode *goyaml.Node) [2]*goyaml.Node {
// Build from innermost to outermost.
currentValue := valueNode
for i := len(parts) - 1; i >= 1; i-- {
innerKey := &goyaml.Node{
Kind: goyaml.ScalarNode,
Tag: "!!str",
Value: parts[i],
}
innerMap := &goyaml.Node{
Kind: goyaml.MappingNode,
Tag: "!!map",
Content: []*goyaml.Node{innerKey, currentValue},
}
currentValue = innerMap
}

outerKey := &goyaml.Node{
Kind: goyaml.ScalarNode,
Tag: "!!str",
Value: parts[0],
}

return [2]*goyaml.Node{outerKey, currentValue}
}

// mergeIntoContent adds a key-value pair to the content slice.
// If the key already exists and both old and new values are MappingNodes,
// the entries are merged (new entries win on conflict).
// Otherwise, the old entry is replaced (last-wins semantics).
func mergeIntoContent(content *[]*goyaml.Node, keyNode, valueNode *goyaml.Node) {
// Look for an existing key with the same value.
for i := 0; i < len(*content); i += 2 {
existingKey := (*content)[i]
if existingKey.Kind == goyaml.ScalarNode && existingKey.Value == keyNode.Value {
existingValue := (*content)[i+1]

// If both are mappings, merge the entries.
if existingValue.Kind == goyaml.MappingNode && valueNode.Kind == goyaml.MappingNode {
mergeMappingNodes(existingValue, valueNode)
return
}

// Otherwise, replace (last-wins).
(*content)[i+1] = valueNode
return
}
}

// Key not found: append.
*content = append(*content, keyNode, valueNode)
}

// mergeMappingNodes merges entries from src into dst.
// If a key exists in both and both values are MappingNodes, they are recursively merged.
// Otherwise, src wins (last-wins).
func mergeMappingNodes(dst, src *goyaml.Node) {
for i := 0; i < len(src.Content); i += 2 {
srcKey := src.Content[i]
srcValue := src.Content[i+1]
mergeIntoContent(&dst.Content, srcKey, srcValue)
}
}
Loading
Loading