Repository: google/yamlfmt (go)

Issue:
Retain line breaks
Sometimes yaml blocks will have a line break between them for human readability, for example:
```
block_a:
  b: "some stuff"

block_b:
  a: "some more info"
```
Today, this will end up being formatted like this:
```
block_a:
  b: "some stuff"
block_b:
  a: "some more info"
```
It would be great not to remove these if possible.

Pull request:
fix #27 retain line break

Patch:
diff --git a/formatters/basic/formatter.go b/formatters/basic/formatter.go
index 3aeeaee..144dd47 100644
--- a/formatters/basic/formatter.go
+++ b/formatters/basic/formatter.go
@@ -43,7 +43,25 @@ func (f *BasicFormatter) Format(yamlContent []byte) ([]byte, error) {
 		reader = bytes.NewReader(yamlContent)
 	}
 
-	decoder := yaml.NewDecoder(reader)
+	encodedContent, err := retainLineBreaks(reader, f.format)
+	if err != nil {
+		return nil, err
+	}
+
+	if f.Config.IncludeDocumentStart {
+		encodedContent = withDocumentStart(encodedContent)
+	}
+	if f.Config.EmojiSupport {
+		encodedContent = hotfix.ParseUnicodePoints(encodedContent)
+	}
+	if f.Config.LineEnding == yamlfmt.LineBreakStyleCRLF {
+		encodedContent = hotfix.WriteCRLFBytes(encodedContent)
+	}
+	return encodedContent, nil
+}
+
+func (f *BasicFormatter) format(in io.Reader) (io.Reader, error) {
+	decoder := yaml.NewDecoder(in)
 	documents := []yaml.Node{}
 	for {
 		var docNode yaml.Node
@@ -66,18 +84,7 @@ func (f *BasicFormatter) Format(yamlContent []byte) ([]byte, error) {
 			return nil, err
 		}
 	}
-
-	encodedContent := b.Bytes()
-	if f.Config.IncludeDocumentStart {
-		encodedContent = withDocumentStart(b.Bytes())
-	}
-	if f.Config.EmojiSupport {
-		encodedContent = hotfix.ParseUnicodePoints(encodedContent)
-	}
-	if f.Config.LineEnding == yamlfmt.LineBreakStyleCRLF {
-		encodedContent = hotfix.WriteCRLFBytes(encodedContent)
-	}
-	return encodedContent, nil
+	return &b, nil
 }
 
 func withDocumentStart(document []byte) []byte {
diff --git a/formatters/basic/line_break.go b/formatters/basic/line_break.go
new file mode 100644
index 0000000..dcac368
--- /dev/null
+++ b/formatters/basic/line_break.go
@@ -0,0 +1,82 @@
+package basic
+
+import (
+	"bufio"
+	"bytes"
+	"io"
+	"strings"
+)
+
+const lineBreakPlaceholder = "#magic___^_^___line"
+
+// retainLineBreaks keeps the line breaks.
+//
+// The basic idea is to insert/remove placeholder comments in the yaml document before and after the format process.
+func retainLineBreaks(in io.Reader, formatter func(io.Reader) (io.Reader, error)) ([]byte, error) {
+	var buf bytes.Buffer
+	scanner := bufio.NewScanner(in)
+	var padding paddinger
+	for scanner.Scan() {
+		txt := scanner.Text()
+		padding.adjust(indentLen(txt))
+		if strings.TrimSpace(txt) == "" { // line break or empty space line.
+			buf.WriteString(padding.String()) // prepend some padding incase literal multiline strings.
+			buf.WriteString(lineBreakPlaceholder)
+			buf.WriteByte('\n')
+			continue
+		}
+		buf.WriteString(txt)
+		buf.WriteByte('\n')
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+
+	formatted, err := formatter(&buf)
+	if err != nil {
+		return nil, err
+	}
+
+	buf.Reset() // reuse
+
+	scanner = bufio.NewScanner(formatted)
+	for scanner.Scan() {
+		txt := scanner.Text()
+		if strings.TrimSpace(txt) == "" {
+			// The basic yaml lib inserts newline when there is a comment(either placeholder or by user)
+			// followed by optional line breaks and a `---` multi-documents.
+			// To fix it, the empty line could only be inserted by us.
+			continue
+		}
+		if strings.HasPrefix(strings.TrimLeft(txt, " "), lineBreakPlaceholder) {
+			buf.WriteByte('\n')
+			continue
+		}
+		buf.WriteString(txt)
+		buf.WriteByte('\n')
+	}
+
+	return buf.Bytes(), scanner.Err()
+}
+
+func indentLen(txt string) int {
+	var cnt int
+	for i := 0; i < len(txt); i++ {
+		if txt[i] != ' ' { // Yaml only allows space to indent.
+			break
+		}
+		cnt++
+	}
+	return cnt
+}
+
+type paddinger struct {
+	strings.Builder
+}
+
+func (p *paddinger) adjust(size int) {
+	// Grows if the given size is larger than us and always return the max padding.
+	for diff := size - p.Len(); diff > 0; diff-- {
+		p.WriteByte(' ')
+	}
+}


Test patch:
diff --git a/formatters/basic/formatter_test.go b/formatters/basic/formatter_test.go
index c33dc70..f5604a3 100644
--- a/formatters/basic/formatter_test.go
+++ b/formatters/basic/formatter_test.go
@@ -113,3 +113,69 @@ func TestEmojiSupport(t *testing.T) {
 		t.Fatalf("expected string to contain 😊, got: %s", resultStr)
 	}
 }
+
+func TestRetainLineBreaks(t *testing.T) {
+	testCases := []struct {
+		desc   string
+		input  string
+		expect string
+	}{
+		{
+			desc: "basic",
+			input: `a:  1
+
+b: 2`,
+			expect: `a: 1
+
+b: 2
+`,
+		},
+		{
+			desc: "multi-doc",
+			input: `a:  1
+
+# tail comment
+---
+b: 2`,
+			expect: `a: 1
+
+# tail comment
+---
+b: 2
+`,
+		},
+		{
+			desc: "literal string",
+			input: `a:  1
+
+shell: |
+  #!/usr/bin/env bash
+
+  # hello, world
+    # bye
+  echo "hello, world"
+`,
+			expect: `a: 1
+
+shell: |
+  #!/usr/bin/env bash
+
+  # hello, world
+    # bye
+  echo "hello, world"
+`,
+		},
+	}
+	f := &basic.BasicFormatter{Config: basic.DefaultConfig()}
+	for _, c := range testCases {
+		t.Run(c.desc, func(t *testing.T) {
+			got, err := f.Format([]byte(c.input))
+			if err != nil {
+				t.Fatalf("expected formatting to pass, returned error: %v", err)
+			}
+			if string(got) != c.expect {
+				t.Fatalf("didn't retain line breaks result: %v, expect %s", string(got), c.expect)
+			}
+		})
+	}
+}