yq/pkg/yqlib/encoder_hcl.go
2025-12-08 20:30:47 +11:00

342 lines
10 KiB
Go

//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 {
// Render as block: key { ... }
block := body.AppendNewBlock(key, nil)
if err := he.encodeNodeAttributes(block.Body(), valueNode); err != nil {
return err
}
} 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
}
// 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
// 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
}
// 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)
}
}