@@ -28,12 +28,14 @@ Example usage:
2828package jamle
2929
3030import (
31+ "bytes"
3132 "fmt"
3233 "os"
3334 "regexp"
3435 "strings"
3536
3637 "github.com/invopop/yaml"
38+ yamlv3 "gopkg.in/yaml.v3"
3739)
3840
3941// placeholders for masking braces in escaped variables
@@ -56,8 +58,82 @@ Before parsing, it recursively expands environment variables within the data.
5658The function performs up to 10 passes to resolve nested variables (e.g., ${A:=${B}})
5759and prevents infinite loops.
5860*/
59- func Unmarshal (data []byte , v interface {}) error {
60- str := string (data )
61+ func Unmarshal (data []byte , v any ) error {
62+ // Parse into YAML AST (comments are stored in node fields, not in scalar values)
63+ var root yamlv3.Node
64+ dec := yamlv3 .NewDecoder (bytes .NewReader (data ))
65+ dec .KnownFields (false )
66+ if err := dec .Decode (& root ); err != nil {
67+ return err
68+ }
69+
70+ // Expand only scalar values (never comments)
71+ var resolveErr error
72+ walkScalars (& root , func (s string ) string {
73+ if resolveErr != nil {
74+ return s
75+ }
76+
77+ out , err := expandEnvInScalar (s )
78+ if err != nil {
79+ resolveErr = err
80+ return s
81+ }
82+
83+ return out
84+ })
85+
86+ if resolveErr != nil {
87+ return resolveErr
88+ }
89+
90+ // Encode back to YAML (comments preserved) then unmarshal via invopop/yaml
91+ var buf bytes.Buffer
92+ enc := yamlv3 .NewEncoder (& buf )
93+ enc .SetIndent (2 )
94+ if err := enc .Encode (& root ); err != nil {
95+ _ = enc .Close ()
96+ return err
97+ }
98+
99+ if err := enc .Close (); err != nil {
100+ return err
101+ }
102+
103+ return yaml .Unmarshal (buf .Bytes (), v )
104+ }
105+
106+ // walkScalars walks the YAML AST recursively and applies fn to the Value of each ScalarNode.
107+ // Comments (HeadComment, LineComment, FootComment) are intentionally not touched.
108+ func walkScalars (n * yamlv3.Node , fn func (string ) string ) {
109+ if n == nil {
110+ return
111+ }
112+
113+ if n .Kind == yamlv3 .ScalarNode {
114+ oldStyle := n .Style
115+ oldTag := n .Tag
116+ oldVal := n .Value
117+
118+ n .Value = fn (n .Value )
119+
120+ // If the scalar was plain and implicitly a string only because it contained ${...},
121+ // clear the tag so YAML can re-resolve types (bool/int/float/null) after expansion.
122+ if oldStyle == 0 && oldTag == "!!str" && oldVal != n .Value {
123+ n .Tag = ""
124+ }
125+ }
126+
127+ for _ , c := range n .Content {
128+ walkScalars (c , fn )
129+ }
130+ }
131+
132+ // expandEnvInScalar expands Bash-style environment variables inside a single YAML scalar value.
133+ // The function operates only on the provided scalar string and has
134+ // no visibility into YAML structure or comments.
135+ func expandEnvInScalar (in string ) (string , error ) {
136+ str := escapedVarRegex .ReplaceAllString (in , maskStart + "$1" + maskEnd )
61137 var resolveErr error
62138
63139 // Mask escaped variables $${VAR} -> \x00VAR\x01
@@ -86,7 +162,7 @@ func Unmarshal(data []byte, v interface{}) error {
86162 })
87163
88164 if resolveErr != nil {
89- return resolveErr
165+ return "" , resolveErr
90166 }
91167
92168 if str == replacement {
@@ -100,7 +176,7 @@ func Unmarshal(data []byte, v interface{}) error {
100176 str = strings .ReplaceAll (str , maskStart , "${" )
101177 str = strings .ReplaceAll (str , maskEnd , "}" )
102178
103- return yaml . Unmarshal ([] byte ( str ), v )
179+ return str , nil
104180}
105181
106182// resolveVariable parses the content inside ${...} and applies Bash-style logic.
0 commit comments