From 5c166e3038d3e509cfaa905d3f7541a7fa5e730f Mon Sep 17 00:00:00 2001 From: Mike Farah Date: Mon, 8 Dec 2025 10:33:06 +1100 Subject: [PATCH] wip - comments --- examples/sample2.hcl | 15 ++++---- pkg/yqlib/decoder_hcl.go | 78 +++++++++++++++++++++++----------------- pkg/yqlib/encoder_hcl.go | 35 ++++++++++++------ pkg/yqlib/hcl_test.go | 23 ++++++++++++ 4 files changed, 100 insertions(+), 51 deletions(-) diff --git a/examples/sample2.hcl b/examples/sample2.hcl index 0ca07a91..ec3efc46 100644 --- a/examples/sample2.hcl +++ b/examples/sample2.hcl @@ -1,9 +1,8 @@ -service "cat" { - process "main" { - command = ["/usr/local/bin/awesome-app", "server"] - } +# Arithmetic with literals and application-provided variables +sum = 1 + addend - process "mgmt" { - command = ["/usr/local/bin/awesome-app", "mgmt"] - } -} \ No newline at end of file +# String interpolation and templates +message = "Hello, ${name}!" + +# Application-provided functions +shouty_message = upper(message) \ No newline at end of file diff --git a/pkg/yqlib/decoder_hcl.go b/pkg/yqlib/decoder_hcl.go index 40752959..17bb7e59 100644 --- a/pkg/yqlib/decoder_hcl.go +++ b/pkg/yqlib/decoder_hcl.go @@ -64,8 +64,9 @@ func extractLineComment(src []byte, endPos int) string { return "" } -// extractLeadingComments extracts comments from the very beginning of the file -func extractLeadingComments(src []byte) string { +// 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 @@ -74,6 +75,8 @@ func extractLeadingComments(src []byte) string { i++ } + lastPos := -1 + // Extract comment lines from the start for i < len(src) && src[i] == '#' { lineStart := i @@ -81,6 +84,7 @@ func extractLeadingComments(src []byte) string { 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' { @@ -93,51 +97,46 @@ func extractLeadingComments(src []byte) string { } if len(comments) > 0 { - return strings.Join(comments, "\n") + return strings.Join(comments, "\n"), lastPos } - return "" + return "", -1 } // extractHeadComment extracts comments before a given start position func extractHeadComment(src []byte, startPos int) string { var comments []string - // Look backwards from startPos for comment lines + // Start just before the token and skip trailing whitespace i := startPos - 1 - - // Skip whitespace backwards to find comment for i >= 0 && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') { i-- } - // If we found a #, extract the comment - if i >= 0 && src[i] == '#' { - // Find the start of this line - lineStart := i - for lineStart > 0 && src[lineStart-1] != '\n' { - lineStart-- - } - - // Extract from line start to comment end - comments = append(comments, strings.TrimSpace(string(src[lineStart:i+1]))) - - // Look for more comment lines above this one - i = lineStart - 1 - for i >= 0 && (src[i] == '\n' || src[i] == '\r') { + for i >= 0 { + // Find line boundaries + lineEnd := i + for i >= 0 && src[i] != '\n' { i-- } + lineStart := i + 1 - for i >= 0 && src[i] == '#' { - lineStart = i - for lineStart > 0 && src[lineStart-1] != '\n' { - lineStart-- - } - comments = append([]string{strings.TrimSpace(string(src[lineStart : i+1]))}, comments...) + line := strings.TrimRight(string(src[lineStart:lineEnd+1]), " \t\r") + trimmed := strings.TrimSpace(line) - i = lineStart - 1 - for i >= 0 && (src[i] == '\n' || src[i] == '\r') { - i-- - } + 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-- } } @@ -176,7 +175,9 @@ func (dec *hclDecoder) Decode() (*CandidateNode, error) { root := &CandidateNode{Kind: MappingNode} // Extract file-level head comments (comments at the very beginning of the file) - if leadingComment := extractLeadingComments(dec.fileBytes); leadingComment != "" { + leadingComment, _ := extractLeadingComments(dec.fileBytes) + leadingUsed := false + if leadingComment != "" { root.HeadComment = leadingComment } @@ -188,7 +189,18 @@ func (dec *hclDecoder) Decode() (*CandidateNode, error) { // Attach comments if any attrRange := attrWithName.Attr.Range() - if headComment := extractHeadComment(dec.fileBytes, attrRange.Start.Byte); headComment != "" { + 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 != "" { diff --git a/pkg/yqlib/encoder_hcl.go b/pkg/yqlib/encoder_hcl.go index b522d5ab..b61cd0f7 100644 --- a/pkg/yqlib/encoder_hcl.go +++ b/pkg/yqlib/encoder_hcl.go @@ -114,22 +114,37 @@ func (he *hclEncoder) injectComments(output []byte, commentMap map[string]string // Convert output to string for easier manipulation result := string(output) - // Look for head comments at the root level - // These are typically comments before the first attribute + // Root-level head comment (stored as ".head") + for path, comment := range commentMap { + if path == ".head" { + trimmed := strings.TrimSpace(comment) + if trimmed != "" && !strings.HasPrefix(result, trimmed) { + result = trimmed + "\n" + result + } + } + } + + // Attribute head comments: insert above matching assignment for path, comment := range commentMap { parts := strings.Split(path, ".") - if len(parts) < 2 { + if len(parts) != 2 { continue } - commentType := parts[len(parts)-1] // "head", "line", or "foot" + key := parts[0] + commentType := parts[1] + if commentType != "head" || key == "" { + continue + } - if commentType == "head" && len(parts) == 2 { - // Root-level head comment - inject at the beginning - // Check if comment not already there - if !strings.HasPrefix(result, strings.TrimSpace(comment)) { - result = comment + "\n" + result - } + trimmed := strings.TrimSpace(comment) + if trimmed == "" { + continue + } + + re := regexp.MustCompile(`(?m)^(\s*)` + regexp.QuoteMeta(key) + `\s*=`) + if re.MatchString(result) { + result = re.ReplaceAllString(result, "$1"+trimmed+"\n$0") } } diff --git a/pkg/yqlib/hcl_test.go b/pkg/yqlib/hcl_test.go index d217b5bc..26314ef2 100644 --- a/pkg/yqlib/hcl_test.go +++ b/pkg/yqlib/hcl_test.go @@ -45,6 +45,23 @@ var multipleBlockLabelKeysExpectedYaml = `service: - "management" ` +var roundtripSample = `# Arithmetic with literals and application-provided variables +sum = 1 + addend + +# String interpolation and templates +message = "Hello, ${name}!" + +# Application-provided functions +shouty_message = upper(message)` + +var roundtripSampleExpected = `# Arithmetic with literals and application-provided variables +sum = 1 + addend +# String interpolation and templates +message = "Hello, ${name}!" +# Application-provided functions +shouty_message = upper(message) +` + var hclFormatScenarios = []formatScenario{ { description: "Simple decode", @@ -250,6 +267,12 @@ var hclFormatScenarios = []formatScenario{ expected: "# Configuration\nport = 8080\n", scenarioType: "roundtrip", }, + { + description: "roundtrip example", + input: roundtripSample, + expected: roundtripSampleExpected, + scenarioType: "roundtrip", + }, } func testHclScenario(t *testing.T, s formatScenario) {