Repository: google/yamlfmt (go)

Issue:
Preserve comments with basic formatter
I didn't realize that the `gopkg.in/yaml.v2` package stripped comments. I'll probably swap libraries for the `basic` formatter to `github.com/goccy/go-yaml` which provides a way to preserve comments.

Pull request:
Update to yaml.v3 and use yaml.Node
Closes #3 

Using yaml.v2 was naive; unmarshalling into a map mangles the key order
and removes comments. Unmarshalling using yaml.v3's far more robust
yaml.Node type means we can preserve key order and comments. Added the
first unit tests for these specific cases, but more tests will need to
come imminently.

Patch:
diff --git a/README.md b/README.md
index 89c1a6a..482b62a 100644
--- a/README.md
+++ b/README.md
@@ -18,26 +18,44 @@ go install github.com/google/yamlfmt/cmd/yamlfmt@latest
 ## Usage
 
 By default, the tool will recursively find all files that match the glob path `**/*.{yaml,yml}` extension and attempt to format them with the [basic formatter](formatters/basic). To run the tool with all default settings, simply run the command with no arguments:
-```
+```bash
 yamlfmt
 ```
 You can also run the command with paths to each individual file, or with glob paths:
-```
+```bash
 yamlfmt x.yaml y.yaml config/**/*.yaml
 ```
 (NOTE: Glob paths are implemented using the [doublestar](https://github.com/bmatcuk/doublestar) package, which is far more flexible than Go's glob implementation. See the doublestar docs for more details.)
 
+## Configuration
+
+The tool can be configured with a `.yamlfmt` configuration file in the working directory. The configuration is specified in yaml.
+
+### Include/Exclude
+
 If you would like to have a consistent configuration for include and exclude paths, you can also use a configuration file. The tool will attempt to read a configuration file named `.yamlfmt` in the directory the tool is run on. In it, you can configure paths to include and exclude, for example:
-```
+```yaml
 include:
-- config/**/*.{yaml,yml}
+  - config/**/*.{yaml,yml}
 exclude:
-- excluded/**/*.yaml
+  - excluded/**/*.yaml
 ```
 
+### Formatter
+
+In your `.yamlfmt` file you can also specify configuration for the formatter if that formatter supports it. To change the indentation level of the basic formatter for example:
+```yaml
+formatter:
+  type: basic
+  indentation: 4
+```
+If the type is not specified, the default formatter will be used. In the tool included in this repo, the default is the [basic formatter](formatters/basic).
+
+## Flags
+
 The CLI supports 3 operation modes:
 
-* Format (default)
+* Format (default, no flags)
     - Format and write the matched files
 * Dry run (`-dry` flag)
     - Format the matched files and output the diff to `stdout`
diff --git a/cmd/yamlfmt/main.go b/cmd/yamlfmt/main.go
index ae5ab22..e007f52 100644
--- a/cmd/yamlfmt/main.go
+++ b/cmd/yamlfmt/main.go
@@ -24,7 +24,7 @@ import (
 	"github.com/google/yamlfmt"
 	"github.com/google/yamlfmt/command"
 	"github.com/google/yamlfmt/formatters/basic"
-	"gopkg.in/yaml.v2"
+	"gopkg.in/yaml.v3"
 )
 
 var (
@@ -88,7 +88,7 @@ func readConfig(path string) (map[string]interface{}, error) {
 		return nil, err
 	}
 	var configData map[string]interface{}
-	err = yaml.UnmarshalStrict(yamlBytes, &configData)
+	err = yaml.Unmarshal(yamlBytes, &configData)
 	if err != nil {
 		return nil, err
 	}
diff --git a/command/command.go b/command/command.go
index c40f4d7..83430a9 100644
--- a/command/command.go
+++ b/command/command.go
@@ -1,3 +1,17 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package command
 
 import (
@@ -5,7 +19,6 @@ import (
 
 	"github.com/google/yamlfmt"
 	"github.com/google/yamlfmt/engine"
-	"github.com/google/yamlfmt/formatters/basic"
 	"github.com/mitchellh/mapstructure"
 )
 
@@ -17,10 +30,15 @@ const (
 	OperationDry
 )
 
+type formatterConfig struct {
+	Type              string                 `mapstructure:"type"`
+	FormatterSettings map[string]interface{} `mapstructure:",remain"`
+}
+
 type commandConfig struct {
-	Include         []string               `mapstructure:"include"`
-	Exclude         []string               `mapstructure:"exclude"`
-	FormatterConfig map[string]interface{} `mapstructure:"formatter"`
+	Include         []string         `mapstructure:"include"`
+	Exclude         []string         `mapstructure:"exclude"`
+	FormatterConfig *formatterConfig `mapstructure:"formatter,omitempty"`
 }
 
 func RunCommand(
@@ -45,17 +63,26 @@ func RunCommand(
 		}
 		formatter = factory.NewDefault()
 	} else {
-		fType, ok := config.FormatterConfig["type"].(string)
-		if !ok {
-			fType = basic.BasicFormatterType
+		var (
+			factory yamlfmt.Factory
+			err     error
+		)
+		if config.FormatterConfig.Type == "" {
+			factory, err = registry.GetDefaultFactory()
+		} else {
+			factory, err = registry.GetFactory(config.FormatterConfig.Type)
 		}
-		factory, err := registry.GetFactory(fType)
 		if err != nil {
 			return err
 		}
-		formatter, err = factory.NewWithConfig(config.FormatterConfig)
-		if err != nil {
-			return err
+
+		if len(config.FormatterConfig.FormatterSettings) > 0 {
+			formatter, err = factory.NewWithConfig(config.FormatterConfig.FormatterSettings)
+			if err != nil {
+				return err
+			}
+		} else {
+			formatter = factory.NewDefault()
 		}
 	}
 
diff --git a/engine/engine.go b/engine/engine.go
index 1777d68..e6ba348 100644
--- a/engine/engine.go
+++ b/engine/engine.go
@@ -1,3 +1,17 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package engine
 
 import (
diff --git a/engine/errors.go b/engine/errors.go
index b8c9638..abc15c0 100644
--- a/engine/errors.go
+++ b/engine/errors.go
@@ -1,3 +1,17 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package engine
 
 import "fmt"
diff --git a/engine/util.go b/engine/util.go
index 1d9bea9..86aa552 100644
--- a/engine/util.go
+++ b/engine/util.go
@@ -1,3 +1,17 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package engine
 
 import (
diff --git a/formatters/basic/README.md b/formatters/basic/README.md
new file mode 100644
index 0000000..e76956f
--- /dev/null
+++ b/formatters/basic/README.md
@@ -0,0 +1,9 @@
+# Basic Formatter
+
+The basic formatter is a barebones formatter that simply takes the data provided, serializes it with [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) and encodes it again. This provides a consistent output format that is very opinionated and cannot be configured.
+
+## Configuration
+
+| Key           | Default | Description |
+|:--------------|:--------|:------------|
+| `indentation` | 2       | The indentation level in spaces to use for the formatted yaml|
diff --git a/formatters/basic/config.go b/formatters/basic/config.go
new file mode 100644
index 0000000..afa3a72
--- /dev/null
+++ b/formatters/basic/config.go
@@ -0,0 +1,25 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package basic
+
+type Config struct {
+	Indent int `mapstructure:"indent"`
+}
+
+func DefaultConfig() *Config {
+	return &Config{
+		Indent: 2,
+	}
+}
diff --git a/formatters/basic/factory.go b/formatters/basic/factory.go
index 03f9741..d64dca0 100644
--- a/formatters/basic/factory.go
+++ b/formatters/basic/factory.go
@@ -16,6 +16,7 @@ package basic
 
 import (
 	"github.com/google/yamlfmt"
+	"github.com/mitchellh/mapstructure"
 )
 
 type BasicFormatterFactory struct{}
@@ -25,9 +26,14 @@ func (f *BasicFormatterFactory) Type() string {
 }
 
 func (f *BasicFormatterFactory) NewDefault() yamlfmt.Formatter {
-	return &BasicFormatter{}
+	return &BasicFormatter{Config: DefaultConfig()}
 }
 
 func (f *BasicFormatterFactory) NewWithConfig(configData map[string]interface{}) (yamlfmt.Formatter, error) {
-	return f.NewDefault(), nil
+	var c Config
+	err := mapstructure.Decode(configData, &c)
+	if err != nil {
+		return nil, err
+	}
+	return &BasicFormatter{Config: &c}, nil
 }
diff --git a/formatters/basic/formatter.go b/formatters/basic/formatter.go
index 6d6056d..194db49 100644
--- a/formatters/basic/formatter.go
+++ b/formatters/basic/formatter.go
@@ -15,26 +15,33 @@
 package basic
 
 import (
-	"gopkg.in/yaml.v2"
+	"bytes"
+
+	"gopkg.in/yaml.v3"
 )
 
 const BasicFormatterType string = "basic"
 
-type BasicFormatter struct{}
+type BasicFormatter struct {
+	Config *Config
+}
 
 func (f *BasicFormatter) Type() string {
 	return BasicFormatterType
 }
 
 func (f *BasicFormatter) Format(yamlContent []byte) ([]byte, error) {
-	var unmarshalled map[string]interface{}
+	var unmarshalled yaml.Node
 	err := yaml.Unmarshal(yamlContent, &unmarshalled)
 	if err != nil {
 		return nil, err
 	}
-	marshalled, err := yaml.Marshal(unmarshalled)
+	var b bytes.Buffer
+	e := yaml.NewEncoder(&b)
+	e.SetIndent(f.Config.Indent)
+	err = e.Encode(&unmarshalled)
 	if err != nil {
 		return nil, err
 	}
-	return marshalled, nil
+	return b.Bytes(), nil
 }
diff --git a/go.mod b/go.mod
index 81bb455..e9892b5 100644
--- a/go.mod
+++ b/go.mod
@@ -6,5 +6,5 @@ require (
 	github.com/bmatcuk/doublestar/v4 v4.2.0
 	github.com/google/go-cmp v0.5.8
 	github.com/mitchellh/mapstructure v1.5.0
-	gopkg.in/yaml.v2 v2.4.0
+	gopkg.in/yaml.v3 v3.0.1
 )
diff --git a/go.sum b/go.sum
index 59efab8..7bcfede 100644
--- a/go.sum
+++ b/go.sum
@@ -6,5 +6,5 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


Test patch:
diff --git a/formatters/basic/formatter_test.go b/formatters/basic/formatter_test.go
new file mode 100644
index 0000000..a3dd934
--- /dev/null
+++ b/formatters/basic/formatter_test.go
@@ -0,0 +1,55 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package basic_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/google/yamlfmt/formatters/basic"
+)
+
+func TestFormatterRetainsComments(t *testing.T) {
+	f := &basic.BasicFormatter{Config: basic.DefaultConfig()}
+
+	yaml := `x: "y" # foo comment`
+
+	s, err := f.Format([]byte(yaml))
+	if err != nil {
+		t.Fatalf("expected formatting to pass, returned error: %v", err)
+	}
+	if !strings.Contains(string(s), "#") {
+		t.Fatal("comment was stripped away")
+	}
+}
+
+func TestFormatterPreservesKeyOrder(t *testing.T) {
+	f := &basic.BasicFormatter{Config: basic.DefaultConfig()}
+
+	yaml := `
+b:
+a:`
+
+	s, err := f.Format([]byte(yaml))
+	if err != nil {
+		t.Fatalf("expected formatting to pass, returned error: %v", err)
+	}
+	unmarshalledStr := string(s)
+	bPos := strings.Index(unmarshalledStr, "b")
+	aPos := strings.Index(unmarshalledStr, "a")
+	if bPos > aPos {
+		t.Fatalf("keys were reordered:\n%s", s)
+	}
+}