//go:build !yq_nohcl package yqlib import ( "fmt" "io" "regexp" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" hclwrite "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" ) type hclEncoder struct { } // NewHclEncoder creates a new HCL encoder func NewHclEncoder() Encoder { return &hclEncoder{} } func (he *hclEncoder) CanHandleAliases() bool { return false } func (he *hclEncoder) PrintDocumentSeparator(_ io.Writer) error { return nil } func (he *hclEncoder) PrintLeadingContent(_ io.Writer, _ string) error { return nil } func (he *hclEncoder) Encode(writer io.Writer, node *CandidateNode) error { log.Debugf("I need to encode %v", NodeToString(node)) f := hclwrite.NewEmptyFile() body := f.Body() if err := he.encodeNode(body, node); err != nil { return fmt.Errorf("failed to encode HCL: %w", err) } // Get the formatted output and remove extra spacing before '=' output := f.Bytes() compactOutput := he.compactSpacing(output) _, err := writer.Write(compactOutput) return err } // compactSpacing removes extra whitespace before '=' in attribute assignments func (he *hclEncoder) compactSpacing(input []byte) []byte { // Use regex to replace multiple spaces before = with single space re := regexp.MustCompile(`(\S)\s{2,}=`) return re.ReplaceAll(input, []byte("$1 =")) } // Helper runes for unquoted identifiers func isHCLIdentifierStart(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' } func isHCLIdentifierPart(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' } // isValidHCLIdentifier checks if a string is a valid HCL identifier (unquoted) func isValidHCLIdentifier(s string) bool { if s == "" { return false } // HCL identifiers must start with a letter or underscore // and contain only letters, digits, underscores, and hyphens for i, r := range s { if i == 0 { if !isHCLIdentifierStart(r) { return false } continue } if !isHCLIdentifierPart(r) { return false } } return true } // tokensForRawHCLExpr produces a minimal token stream for a simple HCL expression so we can // write it without introducing quotes (e.g. function calls like upper(message)). func tokensForRawHCLExpr(expr string) (hclwrite.Tokens, error) { var tokens hclwrite.Tokens for i := 0; i < len(expr); { ch := expr[i] switch { case ch == ' ' || ch == '\t': i++ continue case isHCLIdentifierStart(rune(ch)): start := i i++ for i < len(expr) && isHCLIdentifierPart(rune(expr[i])) { i++ } tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte(expr[start:i])}) continue case ch >= '0' && ch <= '9': start := i i++ for i < len(expr) && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] == '.') { i++ } tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenNumberLit, Bytes: []byte(expr[start:i])}) continue case ch == '(': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenOParen, Bytes: []byte{'('}}) case ch == ')': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCParen, Bytes: []byte{')'}}) case ch == ',': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte{','}}) case ch == '.': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenDot, Bytes: []byte{'.'}}) case ch == '+': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenPlus, Bytes: []byte{'+'}}) case ch == '-': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenMinus, Bytes: []byte{'-'}}) case ch == '*': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenStar, Bytes: []byte{'*'}}) case ch == '/': tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenSlash, Bytes: []byte{'/'}}) default: return nil, fmt.Errorf("unsupported character %q in raw HCL expression", ch) } i++ } return tokens, nil } // encodeNode encodes a CandidateNode directly to HCL, preserving style information func (he *hclEncoder) encodeNode(body *hclwrite.Body, node *CandidateNode) error { if node.Kind != MappingNode { return fmt.Errorf("HCL encoder expects a mapping at the root level") } for i := 0; i < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] key := keyNode.Value // Check if value is a mapping without FlowStyle -> render as block if valueNode.Kind == MappingNode && valueNode.Style != FlowStyle { // Try to extract block labels from a single-entry mapping chain if labels, bodyNode, ok := extractBlockLabels(valueNode); ok { if len(labels) > 1 && mappingChildrenAllMappings(bodyNode) { primaryLabels := labels[:len(labels)-1] nestedType := labels[len(labels)-1] block := body.AppendNewBlock(key, primaryLabels) if handled, err := he.encodeMappingChildrenAsBlocks(block.Body(), nestedType, bodyNode); err != nil { return err } else if !handled { if err := he.encodeNodeAttributes(block.Body(), bodyNode); err != nil { return err } } continue } block := body.AppendNewBlock(key, labels) if err := he.encodeNodeAttributes(block.Body(), bodyNode); err != nil { return err } continue } // If all child values are mappings, treat each child key as a labeled instance of this block type if handled, err := he.encodeMappingChildrenAsBlocks(body, key, valueNode); err != nil { return err } else if handled { continue } // No labels detected, render as unlabeled block block := body.AppendNewBlock(key, nil) if err := he.encodeNodeAttributes(block.Body(), valueNode); err != nil { return err } continue } else { // Render as attribute: key = value // Check the style to determine how to encode strings if valueNode.Kind == ScalarNode && valueNode.Tag == "!!str" { if valueNode.Style&LiteralStyle != 0 { tokens, err := tokensForRawHCLExpr(valueNode.Value) if err != nil { return err } body.SetAttributeRaw(key, tokens) continue } // Check style: DoubleQuotedStyle means template, no style could be unquoted or regular // To distinguish unquoted from regular, we check if the value is a valid identifier if valueNode.Style&DoubleQuotedStyle != 0 && strings.Contains(valueNode.Value, "${") { // Template string - use raw tokens to preserve ${} syntax tokens := hclwrite.Tokens{ {Type: hclsyntax.TokenOQuote, Bytes: []byte{'"'}}, } // Parse the string and add tokens for i := 0; i < len(valueNode.Value); i++ { if i < len(valueNode.Value)-1 && valueNode.Value[i] == '$' && valueNode.Value[i+1] == '{' { // Start of template interpolation tokens = append(tokens, &hclwrite.Token{ Type: hclsyntax.TokenTemplateInterp, Bytes: []byte("${"), }) i++ // skip the '{' // Find the matching '}' start := i + 1 depth := 1 for i++; i < len(valueNode.Value) && depth > 0; i++ { switch valueNode.Value[i] { case '{': depth++ case '}': depth-- } } i-- // back up to the '}' interpExpr := valueNode.Value[start:i] tokens = append(tokens, &hclwrite.Token{ Type: hclsyntax.TokenIdent, Bytes: []byte(interpExpr), }) tokens = append(tokens, &hclwrite.Token{ Type: hclsyntax.TokenTemplateSeqEnd, Bytes: []byte("}"), }) } else { // Regular character tokens = append(tokens, &hclwrite.Token{ Type: hclsyntax.TokenQuotedLit, Bytes: []byte{valueNode.Value[i]}, }) } } tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCQuote, Bytes: []byte{'"'}}) body.SetAttributeRaw(key, tokens) } else if isValidHCLIdentifier(valueNode.Value) && valueNode.Style == 0 { // Could be unquoted identifier - but only if it came from HCL originally // For safety, only use traversal if style is explicitly 0 (not set) // This avoids treating strings from YAML as unquoted traversal := hcl.Traversal{ hcl.TraverseRoot{Name: valueNode.Value}, } body.SetAttributeTraversal(key, traversal) } else { // Regular quoted string - use cty.Value ctyValue, err := nodeToCtyValue(valueNode) if err != nil { return err } body.SetAttributeValue(key, ctyValue) } } else { // Non-string value - use cty.Value ctyValue, err := nodeToCtyValue(valueNode) if err != nil { return err } body.SetAttributeValue(key, ctyValue) } } } return nil } // mappingChildrenAllMappings reports whether all values in a mapping node are non-flow mappings. func mappingChildrenAllMappings(node *CandidateNode) bool { if node == nil || node.Kind != MappingNode || node.Style == FlowStyle { return false } if len(node.Content) == 0 { return false } for i := 0; i < len(node.Content); i += 2 { childVal := node.Content[i+1] if childVal.Kind != MappingNode || childVal.Style == FlowStyle { return false } } return true } // encodeMappingChildrenAsBlocks emits a block for each mapping child, treating the child key as a label. // Returns handled=true when it emitted blocks. func (he *hclEncoder) encodeMappingChildrenAsBlocks(body *hclwrite.Body, blockType string, valueNode *CandidateNode) (bool, error) { if !mappingChildrenAllMappings(valueNode) { return false, nil } for i := 0; i < len(valueNode.Content); i += 2 { childKey := valueNode.Content[i].Value childVal := valueNode.Content[i+1] labels := []string{childKey} if extraLabels, bodyNode, ok := extractBlockLabels(childVal); ok { labels = append(labels, extraLabels...) childVal = bodyNode } block := body.AppendNewBlock(blockType, labels) if err := he.encodeNodeAttributes(block.Body(), childVal); err != nil { return true, err } } return true, nil } // encodeNodeAttributes encodes the attributes of a mapping node (used for blocks) func (he *hclEncoder) encodeNodeAttributes(body *hclwrite.Body, node *CandidateNode) error { if node.Kind != MappingNode { return fmt.Errorf("expected mapping node for block body") } for i := 0; i < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] key := keyNode.Value if valueNode.Kind == MappingNode && valueNode.Style != FlowStyle { if labels, bodyNode, ok := extractBlockLabels(valueNode); ok { if len(labels) > 1 && mappingChildrenAllMappings(bodyNode) { primaryLabels := labels[:len(labels)-1] nestedType := labels[len(labels)-1] block := body.AppendNewBlock(key, primaryLabels) if handled, err := he.encodeMappingChildrenAsBlocks(block.Body(), nestedType, bodyNode); err != nil { return err } else if !handled { if err := he.encodeNodeAttributes(block.Body(), bodyNode); err != nil { return err } } continue } block := body.AppendNewBlock(key, labels) if err := he.encodeNodeAttributes(block.Body(), bodyNode); err != nil { return err } continue } if handled, err := he.encodeMappingChildrenAsBlocks(body, key, valueNode); err != nil { return err } else if handled { continue } block := body.AppendNewBlock(key, nil) if err := he.encodeNodeAttributes(block.Body(), valueNode); err != nil { return err } continue } // Check if this is an unquoted identifier (no DoubleQuotedStyle) if valueNode.Kind == ScalarNode && valueNode.Tag == "!!str" && valueNode.Style&DoubleQuotedStyle == 0 { if valueNode.Style&LiteralStyle != 0 { tokens, err := tokensForRawHCLExpr(valueNode.Value) if err != nil { return err } body.SetAttributeRaw(key, tokens) continue } // Unquoted identifier - use traversal traversal := hcl.Traversal{ hcl.TraverseRoot{Name: valueNode.Value}, } body.SetAttributeTraversal(key, traversal) } else { // Quoted value or non-string - use cty.Value ctyValue, err := nodeToCtyValue(valueNode) if err != nil { return err } body.SetAttributeValue(key, ctyValue) } } return nil } // extractBlockLabels detects a chain of single-entry mappings that encode block labels. // It returns the collected labels and the final mapping to be used as the block body. // Pattern: {label1: {label2: { ... {bodyMap} }}} func extractBlockLabels(node *CandidateNode) ([]string, *CandidateNode, bool) { var labels []string current := node for current != nil && current.Kind == MappingNode && len(current.Content) == 2 { keyNode := current.Content[0] valNode := current.Content[1] if valNode.Kind != MappingNode { break } labels = append(labels, keyNode.Value) // If the child is itself a single mapping entry with a mapping value, keep descending. if len(valNode.Content) == 2 && valNode.Content[1].Kind == MappingNode { current = valNode continue } // Otherwise, we have reached the body mapping. return labels, valNode, true } return nil, nil, false } // nodeToCtyValue converts a CandidateNode directly to cty.Value, preserving order func nodeToCtyValue(node *CandidateNode) (cty.Value, error) { switch node.Kind { case ScalarNode: // Parse scalar value based on its tag switch node.Tag { case "!!bool": return cty.BoolVal(node.Value == "true"), nil case "!!int": var i int64 _, err := fmt.Sscanf(node.Value, "%d", &i) if err != nil { return cty.NilVal, err } return cty.NumberIntVal(i), nil case "!!float": var f float64 _, err := fmt.Sscanf(node.Value, "%f", &f) if err != nil { return cty.NilVal, err } return cty.NumberFloatVal(f), nil case "!!null": return cty.NullVal(cty.DynamicPseudoType), nil default: // Default to string return cty.StringVal(node.Value), nil } case MappingNode: // Preserve order by iterating Content directly m := make(map[string]cty.Value) for i := 0; i < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] v, err := nodeToCtyValue(valueNode) if err != nil { return cty.NilVal, err } m[keyNode.Value] = v } return cty.ObjectVal(m), nil case SequenceNode: vals := make([]cty.Value, len(node.Content)) for i, item := range node.Content { v, err := nodeToCtyValue(item) if err != nil { return cty.NilVal, err } vals[i] = v } return cty.TupleVal(vals), nil case AliasNode: return cty.NilVal, fmt.Errorf("HCL encoder does not support aliases") default: return cty.NilVal, fmt.Errorf("unsupported node kind: %v", node.Kind) } }