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

502 lines
14 KiB
Go

//go:build !yq_nohcl
package yqlib
import (
"fmt"
"io"
"math/big"
"sort"
"strconv"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
)
type hclDecoder struct {
file *hcl.File
fileBytes []byte
readAnything bool
documentIndex uint
}
func NewHclDecoder() Decoder {
return &hclDecoder{}
}
// sortedAttributes returns attributes in declaration order by source position
func sortedAttributes(attrs hclsyntax.Attributes) []*attributeWithName {
var sorted []*attributeWithName
for name, attr := range attrs {
sorted = append(sorted, &attributeWithName{Name: name, Attr: attr})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Attr.Range().Start.Byte < sorted[j].Attr.Range().Start.Byte
})
return sorted
}
type attributeWithName struct {
Name string
Attr *hclsyntax.Attribute
}
// extractLineComment extracts any inline comment after the given position
func extractLineComment(src []byte, endPos int) string {
// Look for # comment after the token
for i := endPos; i < len(src); i++ {
if src[i] == '#' {
// Found comment, extract until end of line
start := i
for i < len(src) && src[i] != '\n' {
i++
}
return strings.TrimSpace(string(src[start:i]))
}
if src[i] == '\n' {
// Hit newline before comment
break
}
// Skip whitespace and other characters
}
return ""
}
// extractLeadingComments extracts comments from the very beginning of the file.
// It returns the comment text and the byte position of the last character in that leading block.
func extractLeadingComments(src []byte) (string, int) {
var comments []string
i := 0
// Skip leading whitespace
for i < len(src) && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') {
i++
}
lastPos := -1
// Extract comment lines from the start
for i < len(src) && src[i] == '#' {
lineStart := i
// Find end of line
for i < len(src) && src[i] != '\n' {
i++
}
lastPos = i - 1
comments = append(comments, strings.TrimSpace(string(src[lineStart:i])))
// Skip newline
if i < len(src) && src[i] == '\n' {
i++
}
// Skip whitespace between comment lines
for i < len(src) && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') {
i++
}
}
if len(comments) > 0 {
return strings.Join(comments, "\n"), lastPos
}
return "", -1
}
// extractHeadComment extracts comments before a given start position
func extractHeadComment(src []byte, startPos int) string {
var comments []string
// Start just before the token and skip trailing whitespace
i := startPos - 1
for i >= 0 && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') {
i--
}
for i >= 0 {
// Find line boundaries
lineEnd := i
for i >= 0 && src[i] != '\n' {
i--
}
lineStart := i + 1
line := strings.TrimRight(string(src[lineStart:lineEnd+1]), " \t\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" {
break
}
if !strings.HasPrefix(trimmed, "#") {
break
}
comments = append([]string{trimmed}, comments...)
// Move to previous line (skip any whitespace/newlines)
i = lineStart - 1
for i >= 0 && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') {
i--
}
}
if len(comments) > 0 {
return strings.Join(comments, "\n")
}
return ""
}
func (dec *hclDecoder) Init(reader io.Reader) error {
data, err := io.ReadAll(reader)
if err != nil {
return err
}
file, diags := hclsyntax.ParseConfig(data, "input.hcl", hcl.Pos{Line: 1, Column: 1})
if diags != nil && diags.HasErrors() {
return fmt.Errorf("hcl parse error: %w", diags)
}
dec.file = file
dec.fileBytes = data
dec.readAnything = false
dec.documentIndex = 0
return nil
}
func (dec *hclDecoder) Decode() (*CandidateNode, error) {
if dec.readAnything {
return nil, io.EOF
}
dec.readAnything = true
if dec.file == nil {
return nil, fmt.Errorf("no hcl file parsed")
}
root := &CandidateNode{Kind: MappingNode}
// Extract file-level head comments (comments at the very beginning of the file)
leadingComment, _ := extractLeadingComments(dec.fileBytes)
leadingUsed := false
if leadingComment != "" {
root.HeadComment = leadingComment
}
// process attributes in declaration order
body := dec.file.Body.(*hclsyntax.Body)
for _, attrWithName := range sortedAttributes(body.Attributes) {
keyNode := createStringScalarNode(attrWithName.Name)
valNode := convertHclExprToNode(attrWithName.Attr.Expr, dec.fileBytes)
// Attach comments if any
attrRange := attrWithName.Attr.Range()
headComment := extractHeadComment(dec.fileBytes, attrRange.Start.Byte)
if !leadingUsed && leadingComment != "" {
// Avoid double-applying the leading file comment to the first attribute
switch headComment {
case leadingComment:
headComment = ""
case "":
headComment = leadingComment
}
leadingUsed = true
}
if headComment != "" {
valNode.HeadComment = headComment
}
if lineComment := extractLineComment(dec.fileBytes, attrRange.End.Byte); lineComment != "" {
valNode.LineComment = lineComment
}
root.AddKeyValueChild(keyNode, valNode)
}
// process blocks
for _, block := range body.Blocks {
addBlockToMapping(root, block, dec.fileBytes)
}
dec.documentIndex++
root.document = dec.documentIndex - 1
return root, nil
}
func hclBodyToNode(body *hclsyntax.Body, src []byte) *CandidateNode {
node := &CandidateNode{Kind: MappingNode}
for _, attrWithName := range sortedAttributes(body.Attributes) {
key := createStringScalarNode(attrWithName.Name)
val := convertHclExprToNode(attrWithName.Attr.Expr, src)
// Attach comments if any
attrRange := attrWithName.Attr.Range()
if headComment := extractHeadComment(src, attrRange.Start.Byte); headComment != "" {
val.HeadComment = headComment
}
if lineComment := extractLineComment(src, attrRange.End.Byte); lineComment != "" {
val.LineComment = lineComment
}
node.AddKeyValueChild(key, val)
}
for _, block := range body.Blocks {
addBlockToMapping(node, block, src)
}
return node
}
// addBlockToMapping nests block type and labels into the parent mapping, merging children.
func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte) {
bodyNode := hclBodyToNode(block.Body, src)
current := parent
// ensure block type mapping exists
var typeNode *CandidateNode
for i := 0; i < len(current.Content); i += 2 {
if current.Content[i].Value == block.Type {
typeNode = current.Content[i+1]
break
}
}
if typeNode == nil {
_, typeNode = current.AddKeyValueChild(createStringScalarNode(block.Type), &CandidateNode{Kind: MappingNode})
}
current = typeNode
// walk labels, creating/merging mappings
for _, label := range block.Labels {
var next *CandidateNode
for i := 0; i < len(current.Content); i += 2 {
if current.Content[i].Value == label {
next = current.Content[i+1]
break
}
}
if next == nil {
_, next = current.AddKeyValueChild(createStringScalarNode(label), &CandidateNode{Kind: MappingNode})
}
current = next
}
// merge body attributes/blocks into the final mapping
for i := 0; i < len(bodyNode.Content); i += 2 {
current.AddKeyValueChild(bodyNode.Content[i], bodyNode.Content[i+1])
}
}
func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode {
// handle literal values directly
switch e := expr.(type) {
case *hclsyntax.LiteralValueExpr:
v := e.Val
if v.IsNull() {
return createScalarNode(nil, "")
}
switch {
case v.Type().Equals(cty.String):
// prefer to extract exact source (to avoid extra quoting) when available
// Prefer the actual cty string value
s := v.AsString()
node := createScalarNode(s, s)
// Don't set style for regular quoted strings - let YAML handle naturally
return node
case v.Type().Equals(cty.Bool):
b := v.True()
return createScalarNode(b, strconv.FormatBool(b))
case v.Type() == cty.Number:
// prefer integers when the numeric value is integral
bf := v.AsBigFloat()
if bf == nil {
// fallback to string
return createStringScalarNode(v.GoString())
}
// check if bf represents an exact integer
if intVal, acc := bf.Int(nil); acc == big.Exact {
s := intVal.String()
return createScalarNode(intVal.Int64(), s)
}
s := bf.Text('g', -1)
return createScalarNode(0.0, s)
case v.Type().IsTupleType() || v.Type().IsListType() || v.Type().IsSetType():
seq := &CandidateNode{Kind: SequenceNode}
it := v.ElementIterator()
for it.Next() {
_, val := it.Element()
// convert cty.Value to a node by wrapping in literal expr via string representation
child := convertCtyValueToNode(val)
seq.AddChild(child)
}
return seq
case v.Type().IsMapType() || v.Type().IsObjectType():
m := &CandidateNode{Kind: MappingNode}
it := v.ElementIterator()
for it.Next() {
key, val := it.Element()
keyStr := key.AsString()
keyNode := createStringScalarNode(keyStr)
valNode := convertCtyValueToNode(val)
m.AddKeyValueChild(keyNode, valNode)
}
return m
default:
// fallback to string
s := v.GoString()
return createStringScalarNode(s)
}
case *hclsyntax.TupleConsExpr:
// parse tuple/list into YAML sequence
seq := &CandidateNode{Kind: SequenceNode}
for _, exprVal := range e.Exprs {
child := convertHclExprToNode(exprVal, src)
seq.AddChild(child)
}
return seq
case *hclsyntax.ObjectConsExpr:
// parse object into YAML mapping
m := &CandidateNode{Kind: MappingNode}
m.Style = FlowStyle // Mark as inline object (flow style) for encoder
for _, item := range e.Items {
// evaluate key expression to get the key string
keyVal, keyDiags := item.KeyExpr.Value(nil)
if keyDiags != nil && keyDiags.HasErrors() {
// fallback: try to extract key from source
r := item.KeyExpr.Range()
start := r.Start.Byte
end := r.End.Byte
if start >= 0 && end >= start && end <= len(src) {
keyNode := createStringScalarNode(strings.TrimSpace(string(src[start:end])))
valNode := convertHclExprToNode(item.ValueExpr, src)
m.AddKeyValueChild(keyNode, valNode)
}
continue
}
keyStr := keyVal.AsString()
keyNode := createStringScalarNode(keyStr)
valNode := convertHclExprToNode(item.ValueExpr, src)
m.AddKeyValueChild(keyNode, valNode)
}
return m
case *hclsyntax.TemplateExpr:
// Reconstruct template string, preserving ${} syntax for interpolations
var parts []string
for _, p := range e.Parts {
switch lp := p.(type) {
case *hclsyntax.LiteralValueExpr:
if lp.Val.Type().Equals(cty.String) {
parts = append(parts, lp.Val.AsString())
} else {
parts = append(parts, lp.Val.GoString())
}
default:
// Non-literal expression - reconstruct with ${} wrapper
r := p.Range()
start := r.Start.Byte
end := r.End.Byte
if start >= 0 && end >= start && end <= len(src) {
exprText := string(src[start:end])
parts = append(parts, "${"+exprText+"}")
} else {
parts = append(parts, fmt.Sprintf("${%v}", p))
}
}
}
combined := strings.Join(parts, "")
node := createScalarNode(combined, combined)
// Set DoubleQuotedStyle for all templates (which includes all quoted strings in HCL)
// This ensures HCL roundtrips preserve quotes, and YAML properly quotes strings with ${}
node.Style = DoubleQuotedStyle
return node
case *hclsyntax.ScopeTraversalExpr:
// Simple identifier/traversal (e.g. unquoted string literal in HCL)
r := e.Range()
start := r.Start.Byte
end := r.End.Byte
if start >= 0 && end >= start && end <= len(src) {
text := strings.TrimSpace(string(src[start:end]))
return createStringScalarNode(text)
}
// Fallback to root name if source unavailable
if len(e.Traversal) > 0 {
if root, ok := e.Traversal[0].(hcl.TraverseRoot); ok {
return createStringScalarNode(root.Name)
}
}
return createStringScalarNode("")
case *hclsyntax.FunctionCallExpr:
// Preserve function calls as raw expressions for roundtrip
r := e.Range()
start := r.Start.Byte
end := r.End.Byte
if start >= 0 && end >= start && end <= len(src) {
text := strings.TrimSpace(string(src[start:end]))
node := createStringScalarNode(text)
node.Style = LiteralStyle
return node
}
node := createStringScalarNode(e.Name)
node.Style = LiteralStyle
return node
default:
// try to evaluate the expression (handles unary, binary ops, etc.)
val, diags := expr.Value(nil)
if diags == nil || !diags.HasErrors() {
// successfully evaluated, convert cty.Value to node
return convertCtyValueToNode(val)
}
// fallback: extract source text for the expression
r := expr.Range()
start := r.Start.Byte
end := r.End.Byte
if start >= 0 && end >= start && end <= len(src) {
text := string(src[start:end])
// Mark as raw expression so encoder can emit without quoting
node := createStringScalarNode(text)
node.Style = LiteralStyle
return node
}
return createStringScalarNode(fmt.Sprintf("%v", expr))
}
}
func convertCtyValueToNode(v cty.Value) *CandidateNode {
if v.IsNull() {
return createScalarNode(nil, "")
}
switch {
case v.Type().Equals(cty.String):
return createScalarNode("", v.AsString())
case v.Type().Equals(cty.Bool):
b := v.True()
return createScalarNode(b, strconv.FormatBool(b))
case v.Type() == cty.Number:
bf := v.AsBigFloat()
if bf == nil {
return createStringScalarNode(v.GoString())
}
if intVal, acc := bf.Int(nil); acc == big.Exact {
s := intVal.String()
return createScalarNode(intVal.Int64(), s)
}
s := bf.Text('g', -1)
return createScalarNode(0.0, s)
case v.Type().IsTupleType() || v.Type().IsListType() || v.Type().IsSetType():
seq := &CandidateNode{Kind: SequenceNode}
it := v.ElementIterator()
for it.Next() {
_, val := it.Element()
seq.AddChild(convertCtyValueToNode(val))
}
return seq
case v.Type().IsMapType() || v.Type().IsObjectType():
m := &CandidateNode{Kind: MappingNode}
it := v.ElementIterator()
for it.Next() {
key, val := it.Element()
keyNode := createStringScalarNode(key.AsString())
valNode := convertCtyValueToNode(val)
m.AddKeyValueChild(keyNode, valNode)
}
return m
default:
return createStringScalarNode(v.GoString())
}
}