diff --git a/examples/sample2.hcl b/examples/sample2.hcl new file mode 100644 index 00000000..8a9fe227 --- /dev/null +++ b/examples/sample2.hcl @@ -0,0 +1,13 @@ +io_mode = "async" + +service "http" "web_proxy" { + listen_addr = "127.0.0.1:8080" + + process "main" { + command = ["/usr/local/bin/awesome-app", "server"] + } + + process "mgmt" { + command = ["/usr/local/bin/awesome-app", "mgmt"] + } +} \ No newline at end of file diff --git a/pkg/yqlib/decoder_hcl.go b/pkg/yqlib/decoder_hcl.go index c2e34856..bf54c8ac 100644 --- a/pkg/yqlib/decoder_hcl.go +++ b/pkg/yqlib/decoder_hcl.go @@ -237,6 +237,36 @@ func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode // 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) @@ -250,8 +280,10 @@ func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode end := r.End.Byte if start >= 0 && end >= start && end <= len(src) { text := string(src[start:end]) - // Unquoted identifier - no style - return createStringScalarNode(text) + // Mark as raw expression so encoder can emit without quoting + node := createStringScalarNode(text) + node.Style = LiteralStyle + return node } return createStringScalarNode(fmt.Sprintf("%v", expr)) } diff --git a/pkg/yqlib/encoder_hcl.go b/pkg/yqlib/encoder_hcl.go index fd41bc3b..c94d7064 100644 --- a/pkg/yqlib/encoder_hcl.go +++ b/pkg/yqlib/encoder_hcl.go @@ -88,6 +88,56 @@ func isValidHCLIdentifier(s string) bool { 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 { @@ -110,6 +160,14 @@ func (he *hclEncoder) encodeNode(body *hclwrite.Body, node *CandidateNode) error // 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, "${") { @@ -199,6 +257,14 @@ func (he *hclEncoder) encodeNodeAttributes(body *hclwrite.Body, node *CandidateN // 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}, diff --git a/pkg/yqlib/hcl_test.go b/pkg/yqlib/hcl_test.go index 3d17eb5a..41c43956 100644 --- a/pkg/yqlib/hcl_test.go +++ b/pkg/yqlib/hcl_test.go @@ -37,6 +37,18 @@ var hclFormatScenarios = []formatScenario{ expected: "message = \"Hello, ${name}!\"\n", scenarioType: "roundtrip", }, + { + description: "Function roundtrip", + input: `shouty_message = upper(message)`, + expected: "shouty_message = upper(message)\n", + scenarioType: "roundtrip", + }, + { + description: "Arithmetic roundtrip", + input: `sum = 1 + addend`, + expected: "sum = 1 + addend\n", + scenarioType: "roundtrip", + }, { description: "number attribute", input: `port = 8080`,