Skip to content

Commit d2d657e

Browse files
committed
HCL improvements
1 parent f4fd8c5 commit d2d657e

File tree

2 files changed

+80
-15
lines changed

2 files changed

+80
-15
lines changed

pkg/yqlib/encoder_hcl.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ type hclEncoder struct {
1919
prefs HclPreferences
2020
}
2121

22+
// commentPathSep is used to join path segments when collecting comments.
23+
// It uses a rarely used ASCII control character to avoid collisions with
24+
// normal key names (including dots).
25+
const commentPathSep = "\x1e"
26+
2227
// NewHclEncoder creates a new HCL encoder
2328
func NewHclEncoder(prefs HclPreferences) Encoder {
2429
return &hclEncoder{prefs: prefs}
@@ -84,7 +89,7 @@ func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commen
8489
if node.Kind == MappingNode {
8590
// Collect root-level head comment if at root (prefix is empty)
8691
if prefix == "" && node.HeadComment != "" {
87-
commentMap[".head"] = node.HeadComment
92+
commentMap[joinCommentPath("__root__", "head")] = node.HeadComment
8893
}
8994

9095
for i := 0; i < len(node.Content); i += 2 {
@@ -93,21 +98,18 @@ func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commen
9398
key := keyNode.Value
9499

95100
// Create a path for this key
96-
path := key
97-
if prefix != "" {
98-
path = prefix + "." + key
99-
}
101+
path := joinCommentPath(prefix, key)
100102

101103
// Store comments from the key (head comments appear before the attribute)
102104
if keyNode.HeadComment != "" {
103-
commentMap[path+".head"] = keyNode.HeadComment
105+
commentMap[joinCommentPath(path, "head")] = keyNode.HeadComment
104106
}
105107
// Store comments from the value (line comments appear after the value)
106108
if valueNode.LineComment != "" {
107-
commentMap[path+".line"] = valueNode.LineComment
109+
commentMap[joinCommentPath(path, "line")] = valueNode.LineComment
108110
}
109111
if valueNode.FootComment != "" {
110-
commentMap[path+".foot"] = valueNode.FootComment
112+
commentMap[joinCommentPath(path, "foot")] = valueNode.FootComment
111113
}
112114

113115
// Recurse into nested mappings
@@ -118,14 +120,22 @@ func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commen
118120
}
119121
}
120122

123+
// joinCommentPath concatenates path segments using commentPathSep, safely handling empty prefixes.
124+
func joinCommentPath(prefix, segment string) string {
125+
if prefix == "" {
126+
return segment
127+
}
128+
return prefix + commentPathSep + segment
129+
}
130+
121131
// injectComments adds collected comments back into the HCL output
122132
func (he *hclEncoder) injectComments(output []byte, commentMap map[string]string) []byte {
123133
// Convert output to string for easier manipulation
124134
result := string(output)
125135

126-
// Root-level head comment (stored as ".head")
136+
// Root-level head comment (stored on the synthetic __root__/head path)
127137
for path, comment := range commentMap {
128-
if path == ".head" {
138+
if path == joinCommentPath("__root__", "head") {
129139
trimmed := strings.TrimSpace(comment)
130140
if trimmed != "" && !strings.HasPrefix(result, trimmed) {
131141
result = trimmed + "\n" + result
@@ -135,13 +145,13 @@ func (he *hclEncoder) injectComments(output []byte, commentMap map[string]string
135145

136146
// Attribute head comments: insert above matching assignment
137147
for path, comment := range commentMap {
138-
parts := strings.Split(path, ".")
139-
if len(parts) != 2 {
148+
parts := strings.Split(path, commentPathSep)
149+
if len(parts) < 2 {
140150
continue
141151
}
142152

143-
key := parts[0]
144-
commentType := parts[1]
153+
commentType := parts[len(parts)-1]
154+
key := parts[len(parts)-2]
145155
if commentType != "head" || key == "" {
146156
continue
147157
}
@@ -271,7 +281,6 @@ func isHCLIdentifierPart(r rune) bool {
271281
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
272282
}
273283

274-
// isValidHCLIdentifier checks if a string is a valid HCL identifier (unquoted)
275284
func isValidHCLIdentifier(s string) bool {
276285
if s == "" {
277286
return false

pkg/yqlib/hcl_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,62 @@ var hclFormatScenarios = []formatScenario{
407407
expected: "# Main config\nenabled = true\nport = 8080\n",
408408
scenarioType: "roundtrip",
409409
},
410+
{
411+
description: "Multiple attributes with comments (comment safety with safe path separator)",
412+
skipDoc: true,
413+
input: "# Database config\ndb_host = \"localhost\"\n# Connection pool\ndb_pool = 10",
414+
expected: "# Database config\ndb_host = \"localhost\"\n# Connection pool\ndb_pool = 10\n",
415+
scenarioType: "roundtrip",
416+
},
417+
{
418+
description: "Nested blocks with head comments",
419+
skipDoc: true,
420+
input: "service \"api\" {\n # Listen address\n listen = \"0.0.0.0:8080\"\n # TLS enabled\n tls = true\n}",
421+
expected: "service \"api\" {\n # Listen address\n listen = \"0.0.0.0:8080\"\n # TLS enabled\n tls = true\n}\n",
422+
scenarioType: "roundtrip",
423+
},
424+
{
425+
description: "Multiple blocks with EncodeSeparate preservation",
426+
skipDoc: true,
427+
input: "resource \"aws_s3_bucket\" \"bucket1\" {\n bucket = \"my-bucket-1\"\n}\nresource \"aws_s3_bucket\" \"bucket2\" {\n bucket = \"my-bucket-2\"\n}",
428+
expected: "resource \"aws_s3_bucket\" \"bucket1\" {\n bucket = \"my-bucket-1\"\n}\nresource \"aws_s3_bucket\" \"bucket2\" {\n bucket = \"my-bucket-2\"\n}\n",
429+
scenarioType: "roundtrip",
430+
},
431+
{
432+
description: "Blocks with same name handled separately",
433+
skipDoc: true,
434+
input: "server \"primary\" { port = 8080 }\nserver \"backup\" { port = 8081 }",
435+
expected: "server \"primary\" {\n port = 8080\n}\nserver \"backup\" {\n port = 8081\n}\n",
436+
scenarioType: "roundtrip",
437+
},
438+
{
439+
description: "Block label with dot roundtrip (commentPathSep)",
440+
skipDoc: true,
441+
input: "service \"api.service\" {\n port = 8080\n}",
442+
expected: "service \"api.service\" {\n port = 8080\n}\n",
443+
scenarioType: "roundtrip",
444+
},
445+
{
446+
description: "Nested template expression",
447+
skipDoc: true,
448+
input: `message = "User: ${username}, Role: ${user_role}"`,
449+
expected: "message = \"User: ${username}, Role: ${user_role}\"\n",
450+
scenarioType: "roundtrip",
451+
},
452+
{
453+
description: "Empty object roundtrip",
454+
skipDoc: true,
455+
input: `obj = {}`,
456+
expected: "obj = {}\n",
457+
scenarioType: "roundtrip",
458+
},
459+
{
460+
description: "Null value in block",
461+
skipDoc: true,
462+
input: `service { optional_field = null }`,
463+
expected: "service {\n optional_field = null\n}\n",
464+
scenarioType: "roundtrip",
465+
},
410466
}
411467

412468
func testHclScenario(t *testing.T, s formatScenario) {

0 commit comments

Comments
 (0)