diff --git a/pkg/yqlib/decoder_hcl.go b/pkg/yqlib/decoder_hcl.go index 96134cf2..40752959 100644 --- a/pkg/yqlib/decoder_hcl.go +++ b/pkg/yqlib/decoder_hcl.go @@ -43,6 +43,110 @@ type attributeWithName struct { Attr *hclsyntax.Attribute } +// extractLineComment extracts any inline comment after the given position +func extractLineComment(src []byte, endPos int) string { + // Look for # comment after the token + for i := endPos; i < len(src); i++ { + if src[i] == '#' { + // Found comment, extract until end of line + start := i + for i < len(src) && src[i] != '\n' { + i++ + } + return strings.TrimSpace(string(src[start:i])) + } + if src[i] == '\n' { + // Hit newline before comment + break + } + // Skip whitespace and other characters + } + return "" +} + +// extractLeadingComments extracts comments from the very beginning of the file +func extractLeadingComments(src []byte) string { + var comments []string + i := 0 + + // Skip leading whitespace + for i < len(src) && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') { + i++ + } + + // Extract comment lines from the start + for i < len(src) && src[i] == '#' { + lineStart := i + // Find end of line + for i < len(src) && src[i] != '\n' { + i++ + } + comments = append(comments, strings.TrimSpace(string(src[lineStart:i]))) + // Skip newline + if i < len(src) && src[i] == '\n' { + i++ + } + // Skip whitespace between comment lines + for i < len(src) && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') { + i++ + } + } + + if len(comments) > 0 { + return strings.Join(comments, "\n") + } + return "" +} + +// 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 + 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') { + i-- + } + + 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...) + + i = lineStart - 1 + for i >= 0 && (src[i] == '\n' || src[i] == '\r') { + i-- + } + } + } + + if len(comments) > 0 { + return strings.Join(comments, "\n") + } + return "" +} + func (dec *hclDecoder) Init(reader io.Reader) error { data, err := io.ReadAll(reader) if err != nil { @@ -71,11 +175,26 @@ 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 != "" { + root.HeadComment = leadingComment + } + // process attributes in declaration order body := dec.file.Body.(*hclsyntax.Body) for _, attrWithName := range sortedAttributes(body.Attributes) { keyNode := createStringScalarNode(attrWithName.Name) valNode := convertHclExprToNode(attrWithName.Attr.Expr, dec.fileBytes) + + // Attach comments if any + attrRange := attrWithName.Attr.Range() + if headComment := extractHeadComment(dec.fileBytes, attrRange.Start.Byte); headComment != "" { + valNode.HeadComment = headComment + } + if lineComment := extractLineComment(dec.fileBytes, attrRange.End.Byte); lineComment != "" { + valNode.LineComment = lineComment + } + root.AddKeyValueChild(keyNode, valNode) } @@ -94,6 +213,16 @@ func hclBodyToNode(body *hclsyntax.Body, src []byte) *CandidateNode { for _, attrWithName := range sortedAttributes(body.Attributes) { key := createStringScalarNode(attrWithName.Name) val := convertHclExprToNode(attrWithName.Attr.Expr, src) + + // Attach comments if any + attrRange := attrWithName.Attr.Range() + if headComment := extractHeadComment(src, attrRange.Start.Byte); headComment != "" { + val.HeadComment = headComment + } + if lineComment := extractLineComment(src, attrRange.End.Byte); lineComment != "" { + val.LineComment = lineComment + } + node.AddKeyValueChild(key, val) } for _, block := range body.Blocks { diff --git a/pkg/yqlib/encoder_hcl.go b/pkg/yqlib/encoder_hcl.go index 16133ddd..b522d5ab 100644 --- a/pkg/yqlib/encoder_hcl.go +++ b/pkg/yqlib/encoder_hcl.go @@ -39,6 +39,11 @@ func (he *hclEncoder) Encode(writer io.Writer, node *CandidateNode) error { f := hclwrite.NewEmptyFile() body := f.Body() + + // Collect comments as we encode + commentMap := make(map[string]string) + he.collectComments(node, "", commentMap) + if err := he.encodeNode(body, node); err != nil { return fmt.Errorf("failed to encode HCL: %w", err) } @@ -47,7 +52,10 @@ func (he *hclEncoder) Encode(writer io.Writer, node *CandidateNode) error { output := f.Bytes() compactOutput := he.compactSpacing(output) - _, err := writer.Write(compactOutput) + // Inject comments back into the output + finalOutput := he.injectComments(compactOutput, commentMap) + + _, err := writer.Write(finalOutput) return err } @@ -58,6 +66,76 @@ func (he *hclEncoder) compactSpacing(input []byte) []byte { return re.ReplaceAll(input, []byte("$1 =")) } +// collectComments recursively collects comments from nodes for later injection +func (he *hclEncoder) collectComments(node *CandidateNode, prefix string, commentMap map[string]string) { + if node == nil { + return + } + + // For mapping nodes, collect comments from values + if node.Kind == MappingNode { + // Collect root-level head comment if at root (prefix is empty) + if prefix == "" && node.HeadComment != "" { + commentMap[".head"] = node.HeadComment + } + + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + key := keyNode.Value + + // Create a path for this key + path := key + if prefix != "" { + path = prefix + "." + key + } + + // Store comments for this value + if valueNode.HeadComment != "" { + commentMap[path+".head"] = valueNode.HeadComment + } + if valueNode.LineComment != "" { + commentMap[path+".line"] = valueNode.LineComment + } + if valueNode.FootComment != "" { + commentMap[path+".foot"] = valueNode.FootComment + } + + // Recurse into nested mappings + if valueNode.Kind == MappingNode { + he.collectComments(valueNode, path, commentMap) + } + } + } +} + +// 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) + + // Look for head comments at the root level + // These are typically comments before the first attribute + for path, comment := range commentMap { + parts := strings.Split(path, ".") + if len(parts) < 2 { + continue + } + + commentType := parts[len(parts)-1] // "head", "line", or "foot" + + 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 + } + } + } + + return []byte(result) +} + // Helper runes for unquoted identifiers func isHCLIdentifierStart(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' diff --git a/pkg/yqlib/hcl_test.go b/pkg/yqlib/hcl_test.go index 194e550e..d217b5bc 100644 --- a/pkg/yqlib/hcl_test.go +++ b/pkg/yqlib/hcl_test.go @@ -238,6 +238,18 @@ var hclFormatScenarios = []formatScenario{ expected: "name = \"app\"\nversion = 1\nenabled = true\n", scenarioType: "roundtrip", }, + { + description: "decode with comments", + input: "# Configuration\nport = 8080 # server port", + expected: "# Configuration\nport: 8080 # server port\n", + scenarioType: "decode", + }, + { + description: "roundtrip with comments", + input: "# Configuration\nport = 8080", + expected: "# Configuration\nport = 8080\n", + scenarioType: "roundtrip", + }, } func testHclScenario(t *testing.T, s formatScenario) {