Merge master into fix/hcl-nonstring-key-panic-2795

Resolve conflict in decoder_hcl.go by keeping typed object keys via
convertCtyValueToNode (preserves int/bool/float keys in YAML) rather than
coercing all keys to strings. Retain PR test case for integer key with
empty object value, adapted to typed key output.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mike Farah
2026-08-19 13:52:03 +10:00
co-authored by Cursor
8 changed files with 91 additions and 53 deletions
+3 -34
View File
@@ -13,7 +13,6 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
)
type hclDecoder struct {
@@ -253,22 +252,6 @@ func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte
}
}
// hclKeyValueAsString converts an evaluated HCL key expression to its string
// representation. HCL object keys may be non-string literals (e.g. a number
// like `1`); calling AsString on those panics. Mirroring OpenTofu/Terragrunt,
// we silently coerce non-string keys to their string form ("1" -> "1",
// true -> "true") instead of panicking.
func hclKeyValueAsString(keyVal cty.Value) (string, error) {
if keyVal.Type() == cty.String {
return keyVal.AsString(), nil
}
strVal, err := convert.Convert(keyVal, cty.String)
if err != nil {
return "", err
}
return strVal.AsString(), nil
}
func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode {
// handle literal values directly
switch e := expr.(type) {
@@ -317,8 +300,7 @@ func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode
it := v.ElementIterator()
for it.Next() {
key, val := it.Element()
keyStr := key.AsString()
keyNode := createStringScalarNode(keyStr)
keyNode := convertCtyValueToNode(key)
valNode := convertCtyValueToNode(val)
m.AddKeyValueChild(keyNode, valNode)
}
@@ -355,20 +337,7 @@ func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode
}
continue
}
keyStr, err := hclKeyValueAsString(keyVal)
if err != nil {
// 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
}
keyNode := createStringScalarNode(keyStr)
keyNode := convertCtyValueToNode(keyVal)
valNode := convertHclExprToNode(item.ValueExpr, src)
m.AddKeyValueChild(keyNode, valNode)
}
@@ -489,7 +458,7 @@ func convertCtyValueToNode(v cty.Value) *CandidateNode {
it := v.ElementIterator()
for it.Next() {
key, val := it.Element()
keyNode := createStringScalarNode(key.AsString())
keyNode := convertCtyValueToNode(key)
valNode := convertCtyValueToNode(val)
m.AddKeyValueChild(keyNode, valNode)
}
+37 -9
View File
@@ -176,6 +176,41 @@ var hclFormatScenarios = []formatScenario{
expected: "obj: {a: 1, b: \"two\"}\n",
scenarioType: "decode",
},
{
description: "object with integer keys",
skipDoc: true,
input: `obj = { 1 = "one", 2 = "two" }`,
expected: "obj: {1: \"one\", 2: \"two\"}\n",
scenarioType: "decode",
},
{
description: "object with boolean keys",
skipDoc: true,
input: `obj = { (true) = "yes", (false) = "no" }`,
expected: "obj: {true: \"yes\", false: \"no\"}\n",
scenarioType: "decode",
},
{
description: "object with float keys",
skipDoc: true,
input: `obj = { (3.14) = "pi" }`,
expected: "obj: {3.14: \"pi\"}\n",
scenarioType: "decode",
},
{
description: "object with mixed scalar keys",
skipDoc: true,
input: `obj = { a = 1, 1 = "one", (true) = "yes" }`,
expected: "obj: {a: 1, 1: \"one\", true: \"yes\"}\n",
scenarioType: "decode",
},
{
description: "nested object with integer keys",
skipDoc: true,
input: `config = { levels = { 1 = "debug", 2 = "info" } }`,
expected: "config: {levels: {1: \"debug\", 2: \"info\"}}\n",
scenarioType: "decode",
},
{
description: "nested block",
skipDoc: true,
@@ -473,17 +508,10 @@ var hclFormatScenarios = []formatScenario{
scenarioType: "roundtrip",
},
{
description: "Non-string object keys are coerced to strings",
description: "object with integer key and empty value",
skipDoc: true,
input: `intdict = { 1 = {} }`,
expected: "intdict: {\"1\": {}}\n",
scenarioType: "decode",
},
{
description: "Mixed non-string object keys are coerced to strings",
skipDoc: true,
input: `d = { 1 = "a", 2 = "b", true = "c" }`,
expected: "d: {\"1\": \"a\", \"2\": \"b\", \"true\": \"c\"}\n",
expected: "intdict: {1: {}}\n",
scenarioType: "decode",
},
}
+1 -1
View File
@@ -369,7 +369,7 @@ var tomlScenarios = []formatScenario{
skipDoc: true,
description: "blank",
input: `A = "hello`,
expectedError: `bad file 'sample.yml': basic string not terminated by "`,
expectedError: `bad file 'sample.yml': unterminated basic string`,
scenarioType: "decode-error",
},
{
+1 -1
View File
@@ -50,7 +50,7 @@ func readStream(filename string) (io.Reader, error) {
}
func writeString(writer io.Writer, txt string) error {
_, errorWriting := writer.Write([]byte(txt))
_, errorWriting := io.WriteString(writer, txt)
return errorWriting
}
+41
View File
@@ -0,0 +1,41 @@
package yqlib
import (
"bufio"
"bytes"
"io"
"testing"
"github.com/mikefarah/yq/v4/test"
)
// plainWriter only implements io.Writer, so io.WriteString must fall back to Write.
type plainWriter struct {
buf bytes.Buffer
}
func (w *plainWriter) Write(p []byte) (int, error) {
return w.buf.Write(p)
}
func TestWriteStringToStringWriter(t *testing.T) {
var buf bytes.Buffer
writer := bufio.NewWriter(&buf)
test.AssertResult(t, nil, writeString(writer, "hello world"))
test.AssertResult(t, nil, writer.Flush())
test.AssertResult(t, "hello world", buf.String())
}
func TestWriteStringToPlainWriter(t *testing.T) {
writer := &plainWriter{}
test.AssertResult(t, nil, writeString(writer, "hello world"))
test.AssertResult(t, "hello world", writer.buf.String())
}
func TestWriteStringDoesNotAllocate(t *testing.T) {
writer := bufio.NewWriter(io.Discard)
allocations := testing.AllocsPerRun(100, func() {
_ = writeString(writer, "hello world")
})
test.AssertResult(t, 0.0, allocations)
}