From d2d657eacc2cbba1dbfc29afd11198a855cc4db5 Mon Sep 17 00:00:00 2001 From: Mike Farah Date: Tue, 9 Dec 2025 19:49:34 +1100 Subject: [PATCH] HCL improvements --- pkg/yqlib/encoder_hcl.go | 39 +++++++++++++++++----------- pkg/yqlib/hcl_test.go | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/pkg/yqlib/encoder_hcl.go b/pkg/yqlib/encoder_hcl.go index cc7d5806..af8315f7 100644 --- a/pkg/yqlib/encoder_hcl.go +++ b/pkg/yqlib/encoder_hcl.go @@ -19,6 +19,11 @@ type hclEncoder struct { prefs HclPreferences } +// commentPathSep is used to join path segments when collecting comments. +// It uses a rarely used ASCII control character to avoid collisions with +// normal key names (including dots). +const commentPathSep = "\x1e" + // NewHclEncoder creates a new HCL encoder func NewHclEncoder(prefs HclPreferences) Encoder { return &hclEncoder{prefs: prefs} @@ -84,7 +89,7 @@ func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commen if node.Kind == MappingNode { // Collect root-level head comment if at root (prefix is empty) if prefix == "" && node.HeadComment != "" { - commentMap[".head"] = node.HeadComment + commentMap[joinCommentPath("__root__", "head")] = node.HeadComment } for i := 0; i < len(node.Content); i += 2 { @@ -93,21 +98,18 @@ func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commen key := keyNode.Value // Create a path for this key - path := key - if prefix != "" { - path = prefix + "." + key - } + path := joinCommentPath(prefix, key) // Store comments from the key (head comments appear before the attribute) if keyNode.HeadComment != "" { - commentMap[path+".head"] = keyNode.HeadComment + commentMap[joinCommentPath(path, "head")] = keyNode.HeadComment } // Store comments from the value (line comments appear after the value) if valueNode.LineComment != "" { - commentMap[path+".line"] = valueNode.LineComment + commentMap[joinCommentPath(path, "line")] = valueNode.LineComment } if valueNode.FootComment != "" { - commentMap[path+".foot"] = valueNode.FootComment + commentMap[joinCommentPath(path, "foot")] = valueNode.FootComment } // Recurse into nested mappings @@ -118,14 +120,22 @@ func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commen } } +// joinCommentPath concatenates path segments using commentPathSep, safely handling empty prefixes. +func joinCommentPath(prefix, segment string) string { + if prefix == "" { + return segment + } + return prefix + commentPathSep + segment +} + // injectComments adds collected comments back into the HCL output func (he *hclEncoder) injectComments(output []byte, commentMap map[string]string) []byte { // Convert output to string for easier manipulation result := string(output) - // Root-level head comment (stored as ".head") + // Root-level head comment (stored on the synthetic __root__/head path) for path, comment := range commentMap { - if path == ".head" { + if path == joinCommentPath("__root__", "head") { trimmed := strings.TrimSpace(comment) if trimmed != "" && !strings.HasPrefix(result, trimmed) { result = trimmed + "\n" + result @@ -135,13 +145,13 @@ func (he *hclEncoder) injectComments(output []byte, commentMap map[string]string // Attribute head comments: insert above matching assignment for path, comment := range commentMap { - parts := strings.Split(path, ".") - if len(parts) != 2 { + parts := strings.Split(path, commentPathSep) + if len(parts) < 2 { continue } - key := parts[0] - commentType := parts[1] + commentType := parts[len(parts)-1] + key := parts[len(parts)-2] if commentType != "head" || key == "" { continue } @@ -271,7 +281,6 @@ 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 diff --git a/pkg/yqlib/hcl_test.go b/pkg/yqlib/hcl_test.go index b81934d9..963067ea 100644 --- a/pkg/yqlib/hcl_test.go +++ b/pkg/yqlib/hcl_test.go @@ -407,6 +407,62 @@ var hclFormatScenarios = []formatScenario{ expected: "# Main config\nenabled = true\nport = 8080\n", scenarioType: "roundtrip", }, + { + description: "Multiple attributes with comments (comment safety with safe path separator)", + skipDoc: true, + input: "# Database config\ndb_host = \"localhost\"\n# Connection pool\ndb_pool = 10", + expected: "# Database config\ndb_host = \"localhost\"\n# Connection pool\ndb_pool = 10\n", + scenarioType: "roundtrip", + }, + { + description: "Nested blocks with head comments", + skipDoc: true, + input: "service \"api\" {\n # Listen address\n listen = \"0.0.0.0:8080\"\n # TLS enabled\n tls = true\n}", + expected: "service \"api\" {\n # Listen address\n listen = \"0.0.0.0:8080\"\n # TLS enabled\n tls = true\n}\n", + scenarioType: "roundtrip", + }, + { + description: "Multiple blocks with EncodeSeparate preservation", + skipDoc: true, + input: "resource \"aws_s3_bucket\" \"bucket1\" {\n bucket = \"my-bucket-1\"\n}\nresource \"aws_s3_bucket\" \"bucket2\" {\n bucket = \"my-bucket-2\"\n}", + expected: "resource \"aws_s3_bucket\" \"bucket1\" {\n bucket = \"my-bucket-1\"\n}\nresource \"aws_s3_bucket\" \"bucket2\" {\n bucket = \"my-bucket-2\"\n}\n", + scenarioType: "roundtrip", + }, + { + description: "Blocks with same name handled separately", + skipDoc: true, + input: "server \"primary\" { port = 8080 }\nserver \"backup\" { port = 8081 }", + expected: "server \"primary\" {\n port = 8080\n}\nserver \"backup\" {\n port = 8081\n}\n", + scenarioType: "roundtrip", + }, + { + description: "Block label with dot roundtrip (commentPathSep)", + skipDoc: true, + input: "service \"api.service\" {\n port = 8080\n}", + expected: "service \"api.service\" {\n port = 8080\n}\n", + scenarioType: "roundtrip", + }, + { + description: "Nested template expression", + skipDoc: true, + input: `message = "User: ${username}, Role: ${user_role}"`, + expected: "message = \"User: ${username}, Role: ${user_role}\"\n", + scenarioType: "roundtrip", + }, + { + description: "Empty object roundtrip", + skipDoc: true, + input: `obj = {}`, + expected: "obj = {}\n", + scenarioType: "roundtrip", + }, + { + description: "Null value in block", + skipDoc: true, + input: `service { optional_field = null }`, + expected: "service {\n optional_field = null\n}\n", + scenarioType: "roundtrip", + }, } func testHclScenario(t *testing.T, s formatScenario) {