Repository: google/yamlfmt (go)

Issue:
Feature request: allow to retain some empty lines
Imagine I have this file:

```yaml
---
# https://taskfile.dev/styleguide/

version: "3"

env:
  PYTHON_VERSION: "3.11.2"

tasks:
  setup-python-interpreter:
    cmds:
      - pyenv install --skip-existing {{.PYTHON_VERSION}}
      - pyenv local {{.PYTHON_VERSION}}
    desc: uses pyenv to locally install python
  
  setup-venv:
    cmds:
      - python -m venv venv
    desc: sets up python venv

```

As far as I understand, I can do one of two things:
- Either set `retain_line_breaks` to `true`, which means I could end up with extra lines even after formatting, for example:
```yaml
---
# https://taskfile.dev/styleguide/





version: "3"

env:
  PYTHON_VERSION: "3.11.2"

tasks:
  setup-python-interpreter:
    cmds:
      - pyenv install --skip-existing {{.PYTHON_VERSION}}
      - pyenv local {{.PYTHON_VERSION}}
    desc: uses pyenv to locally install python



  setup-venv:
    cmds:
      - python -m venv venv
    desc: sets up python venv
```
- Or I could set `retain_line_breaks` to `false`, which means I only end up with:
```yaml
---
# https://taskfile.dev/styleguide/
version: "3"
env:
  PYTHON_VERSION: "3.11.2"
tasks:
  setup-python-interpreter:
    cmds:
      - pyenv install --skip-existing {{.PYTHON_VERSION}}
      - pyenv local {{.PYTHON_VERSION}}
    desc: uses pyenv to locally install python
  setup-venv:
    cmds:
      - python -m venv venv
    desc: sets up python venv
```

I would like to be able to set `retain_line_breaks` to a number, for example, 1, so that I can have a clear separation of one line, but never more, like the Taskfile style guide [suggests](https://taskfile.dev/styleguide/#add-spaces-between-tasks), and I personally agree.

If this is something relevant I could try to have a look if you are accepting contributors :slightly_smiling_face:

Pull request description:
Retain single line breaks
Closes #152 

This PR adds a new formatter config option `retain_line_breaks_single` which will retain line breaks, but in large groups of line breaks only keep one at a time. So this:
```yaml
a: 1



b: 1
```
Becomes:
```yaml
a: 1

b: 1
```

Patch:
diff --git a/docs/config-file.md b/docs/config-file.md
index 3d8f12b..15a5489 100644
--- a/docs/config-file.md
+++ b/docs/config-file.md
@@ -57,18 +57,19 @@ The basic formatter is a barebones formatter that simply takes the data provided
 
 ### Configuration
 
-| Key                      | Type           | Default | Description |
-|:-------------------------|:---------------|:--------|:------------|
-| `indent`                 | int            | 2       | The indentation level in spaces to use for the formatted yaml|
-| `include_document_start` | bool           | false   | Include `---` at document start |
-| `line_ending`            | `lf` or `crlf` | `crlf` on Windows, `lf` otherwise | Parse and write the file with "lf" or "crlf" line endings. This setting will be overwritten by the global `line_ending`. |
-| `retain_line_breaks`     | bool           | false   | Retain line breaks in formatted yaml |
-| `disallow_anchors`       | bool           | false   | If true, reject any YAML anchors or aliases found in the document. |
-| `max_line_length`        | int            | 0       | Set the maximum line length (see notes below). if not set, defaults to 0 which means no limit. |
-| `scan_folded_as_literal` | bool           | false   | Option that will preserve newlines in folded block scalars (blocks that start with `>`). |
-| `indentless_arrays`      | bool           | false   | Render `-` array items (block sequence items) without an increased indent. |
-| `drop_merge_tag`         | bool           | false   | Assume that any well formed merge using just a `<<` token will be a merge, and drop the `!!merge` tag from the formatted result. |
-| `pad_line_comments`      | int            | 1       | The number of padding spaces to insert before line comments. |
+| Key                         | Type           | Default | Description |
+|:----------------------------|:---------------|:--------|:------------|
+| `indent`                    | int            | 2       | The indentation level in spaces to use for the formatted yaml. |
+| `include_document_start`    | bool           | false   | Include `---` at document start. |
+| `line_ending`               | `lf` or `crlf` | `crlf` on Windows, `lf` otherwise | Parse and write the file with "lf" or "crlf" line endings. This setting will be overwritten by the global `line_ending`. |
+| `retain_line_breaks`        | bool           | false   | Retain line breaks in formatted yaml. |
+| `retain_line_breaks_single` | bool           | false   | (NOTE: Takes precedence over `retain_line_breaks`) Retain line breaks in formatted yaml, but only keep a single line in groups of many blank lines. |
+| `disallow_anchors`          | bool           | false   | If true, reject any YAML anchors or aliases found in the document. |
+| `max_line_length`           | int            | 0       | Set the maximum line length (see notes below). if not set, defaults to 0 which means no limit. |
+| `scan_folded_as_literal`    | bool           | false   | Option that will preserve newlines in folded block scalars (blocks that start with `>`). |
+| `indentless_arrays`         | bool           | false   | Render `-` array items (block sequence items) without an increased indent. |
+| `drop_merge_tag`            | bool           | false   | Assume that any well formed merge using just a `<<` token will be a merge, and drop the `!!merge` tag from the formatted result. |
+| `pad_line_comments`         | int            | 1       | The number of padding spaces to insert before line comments. |
 
 ### Note on `max_line_length`
 
diff --git a/formatters/basic/config.go b/formatters/basic/config.go
index 16fff7d..6aea472 100644
--- a/formatters/basic/config.go
+++ b/formatters/basic/config.go
@@ -21,16 +21,17 @@ import (
 )
 
 type Config struct {
-	Indent               int                    `mapstructure:"indent"`
-	IncludeDocumentStart bool                   `mapstructure:"include_document_start"`
-	LineEnding           yamlfmt.LineBreakStyle `mapstructure:"line_ending"`
-	LineLength           int                    `mapstructure:"max_line_length"`
-	RetainLineBreaks     bool                   `mapstructure:"retain_line_breaks"`
-	DisallowAnchors      bool                   `mapstructure:"disallow_anchors"`
-	ScanFoldedAsLiteral  bool                   `mapstructure:"scan_folded_as_literal"`
-	IndentlessArrays     bool                   `mapstructure:"indentless_arrays"`
-	DropMergeTag         bool                   `mapstructure:"drop_merge_tag"`
-	PadLineComments      int                    `mapstructure:"pad_line_comments"`
+	Indent                 int                    `mapstructure:"indent"`
+	IncludeDocumentStart   bool                   `mapstructure:"include_document_start"`
+	LineEnding             yamlfmt.LineBreakStyle `mapstructure:"line_ending"`
+	LineLength             int                    `mapstructure:"max_line_length"`
+	RetainLineBreaks       bool                   `mapstructure:"retain_line_breaks"`
+	RetainLineBreaksSingle bool                   `mapstructure:"retain_line_breaks_single"`
+	DisallowAnchors        bool                   `mapstructure:"disallow_anchors"`
+	ScanFoldedAsLiteral    bool                   `mapstructure:"scan_folded_as_literal"`
+	IndentlessArrays       bool                   `mapstructure:"indentless_arrays"`
+	DropMergeTag           bool                   `mapstructure:"drop_merge_tag"`
+	PadLineComments        int                    `mapstructure:"pad_line_comments"`
 }
 
 func DefaultConfig() *Config {
diff --git a/formatters/basic/features.go b/formatters/basic/features.go
index eddb6a4..976c2d6 100644
--- a/formatters/basic/features.go
+++ b/formatters/basic/features.go
@@ -23,12 +23,12 @@ import (
 
 func ConfigureFeaturesFromConfig(config *Config) yamlfmt.FeatureList {
 	features := []yamlfmt.Feature{}
-	if config.RetainLineBreaks {
+	if config.RetainLineBreaks || config.RetainLineBreaksSingle {
 		lineSep, err := config.LineEnding.Separator()
 		if err != nil {
 			lineSep = "\n"
 		}
-		featLineBreak := hotfix.MakeFeatureRetainLineBreak(lineSep)
+		featLineBreak := hotfix.MakeFeatureRetainLineBreak(lineSep, config.RetainLineBreaksSingle)
 		features = append(features, featLineBreak)
 	}
 	return features
diff --git a/internal/hotfix/retain_line_break.go b/internal/hotfix/retain_line_break.go
index 14ed738..05504e8 100644
--- a/internal/hotfix/retain_line_break.go
+++ b/internal/hotfix/retain_line_break.go
@@ -42,31 +42,37 @@ func (p *paddinger) adjust(txt string) {
 	}
 }
 
-func MakeFeatureRetainLineBreak(linebreakStr string) yamlfmt.Feature {
+func MakeFeatureRetainLineBreak(linebreakStr string, chomp bool) yamlfmt.Feature {
 	return yamlfmt.Feature{
 		Name:         "Retain Line Breaks",
-		BeforeAction: replaceLineBreakFeature(linebreakStr),
+		BeforeAction: replaceLineBreakFeature(linebreakStr, chomp),
 		AfterAction:  restoreLineBreakFeature(linebreakStr),
 	}
 }
 
-func replaceLineBreakFeature(newlineStr string) yamlfmt.FeatureFunc {
+func replaceLineBreakFeature(newlineStr string, chomp bool) yamlfmt.FeatureFunc {
 	return func(content []byte) ([]byte, error) {
 		var buf bytes.Buffer
 		reader := bytes.NewReader(content)
 		scanner := bufio.NewScanner(reader)
+		var inLineBreaks bool
 		var padding paddinger
 		for scanner.Scan() {
 			txt := scanner.Text()
 			padding.adjust(txt)
 			if strings.TrimSpace(txt) == "" { // line break or empty space line.
+				if chomp && inLineBreaks {
+					continue
+				}
 				buf.WriteString(padding.String()) // prepend some padding incase literal multiline strings.
 				buf.WriteString(lineBreakPlaceholder)
 				buf.WriteString(newlineStr)
-				continue
+				inLineBreaks = true
+			} else {
+				buf.WriteString(txt)
+				buf.WriteString(newlineStr)
+				inLineBreaks = false
 			}
-			buf.WriteString(txt)
-			buf.WriteString(newlineStr)
 		}
 		return buf.Bytes(), scanner.Err()
 	}


Test patch:
diff --git a/formatters/basic/formatter_test.go b/formatters/basic/formatter_test.go
index bd5f338..39d666e 100644
--- a/formatters/basic/formatter_test.go
+++ b/formatters/basic/formatter_test.go
@@ -125,12 +125,13 @@ func TestEmojiSupport(t *testing.T) {
 
 func TestRetainLineBreaks(t *testing.T) {
 	testCases := []struct {
-		desc   string
+		name   string
 		input  string
 		expect string
+		single bool
 	}{
 		{
-			desc: "basic",
+			name: "basic",
 			input: `a:  1
 
 b: 2`,
@@ -140,7 +141,7 @@ b: 2
 `,
 		},
 		{
-			desc: "multi-doc",
+			name: "multi-doc",
 			input: `a:  1
 
 # tail comment
@@ -154,7 +155,7 @@ b: 2
 `,
 		},
 		{
-			desc: "literal string",
+			name: "literal string",
 			input: `a:  1
 
 shell: |
@@ -175,7 +176,7 @@ shell: |
 `,
 		},
 		{
-			desc: "multi level nested literal string",
+			name: "multi level nested literal string",
 			input: `a:  1
 x:
   y:
@@ -192,20 +193,41 @@ x:
 
         # bye
       echo "hello, world"
+`,
+		},
+		{
+			name:   "retain single line break",
+			single: true,
+			input: `a: 1
+
+
+
+
+b: 2
+
+
+c: 3
+`,
+			expect: `a: 1
+
+b: 2
+
+c: 3
 `,
 		},
 	}
-	config := basic.DefaultConfig()
-	config.RetainLineBreaks = true
-	f := newFormatter(config)
-	for _, c := range testCases {
-		t.Run(c.desc, func(t *testing.T) {
-			got, err := f.Format([]byte(c.input))
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			config := basic.DefaultConfig()
+			config.RetainLineBreaks = true
+			config.RetainLineBreaksSingle = tc.single
+			f := newFormatter(config)
+			got, err := f.Format([]byte(tc.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\nresult: %v\nexpect %s", string(got), c.expect)
+			if string(got) != tc.expect {
+				t.Fatalf("didn't retain line breaks\nresult: %v\nexpect %s", string(got), tc.expect)
 			}
 		})
 	}