Repository: google/yamlfmt (go)

Issue:
Provide filename instead of diff to stdout
When integrating this into our repo we run a wrapped yamlfmt as part of a suite of misc checks. In our case the diff piped to stdout can be quite large and not very interesting in my opinion, the more helpful approach in our case would be to just provide the path of the files with incorrect formatting. Would it be possible to add this behavior under some flag?

Pull request:
engine: overhauled with interface and better code
While addressing the new quiet mode, the overall weakness of the engine code was very exposed. I decided to make the effort to improve it, creating an interface and a far more sane code path overall that will be more maintainable in the long term. This also opens up the opportunity for me to create a new alternate engine that will do all of this formatting and processing in parallel for users with larger codebases.

Closes #85 

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 405b4e6..eadb715 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -23,7 +23,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: [ '1.19' ]
+        go: [ '1.20' ]
     steps:
       - uses: actions/checkout@v3
 
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index e565ec3..ed117d0 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -11,7 +11,7 @@ jobs:
     if: startsWith(github.ref, 'refs/tags/')
     strategy:
       matrix:
-        version: ['1.19']
+        version: ['1.20']
     env:
       GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
     steps:
diff --git a/README.md b/README.md
index 3c04059..96b9ad8 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,8 @@ The CLI supports the following flags/arguments:
 	- Format the matched files and output the diff to `stdout`
 * Lint (`-lint` flag)
 	- Format the matched files and output the diff to `stdout`, exits with status 1 if there are any differences
+* Quiet mode (`-quiet` flag)
+  - Affects `-lint` and `-dry` so they only output the filepaths with differences rather than the full diff contents
 * Stdin (just `-` or `/dev/stdin` argument, or `-in` flag)
 	- Format the yaml data from `stdin` and output the result to `stdout`
 * Custom config path (`-conf` flag)
@@ -60,15 +62,9 @@ The CLI supports the following flags/arguments:
 
 # Configuration File
 
-The `yamlfmt` command can be configured through a yaml configuration file. The tool looks for the config file in the following order:
+The `yamlfmt` command can be configured through a yaml file called `.yamlfmt`.
 
-1. Specified in the `--conf` flag (if this is an invalid path or doesn't exist, the tool will fail)
-2. A `.yamlfmt` file in the current working directory
-3. A `yamlfmt` folder with a `.yamlfmt` file in the system config directory (`$XDG_CONFIG_HOME`, `$HOME/.config`, `%LOCALAPPDATA%`) e.g. `$HOME/.config/yamlfmt/.yamlfmt`
-
-If none of these are found, the tool's default configuration will be used.
-
-### Include/Exclude
+## 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
@@ -78,7 +74,7 @@ exclude:
   - excluded/**/*.yaml
 ```
 
-### Line Ending
+## Line Ending
 
 The default line ending is `lf` (Unix style, Mac/Linux). The line ending can be changed to `crlf` (Windows style) with the `line_ending` setting:
 ```yaml
@@ -86,7 +82,7 @@ line_ending: crlf
 ```
 This setting will be sent to any formatter as a config field called `line_ending`. If a `line_ending` is specified in the formatter, this will overwrite it. New formatters are free to ignore this setting if they don't need it, but any formatter provided by this repo will handle it accordingly.
 
-### Formatter
+## 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
diff --git a/cmd/yamlfmt/flags.go b/cmd/yamlfmt/flags.go
index c9f2832..86d2b60 100644
--- a/cmd/yamlfmt/flags.go
+++ b/cmd/yamlfmt/flags.go
@@ -29,6 +29,7 @@ operation without performing it.`)
 	flagIn         *bool   = flag.Bool("in", false, "Format yaml read from stdin and output to stdout")
 	flagConf       *string = flag.String("conf", "", "Read yamlfmt config from this path")
 	flagDoublestar *bool   = flag.Bool("dstar", false, "Use doublestar globs for include and exclude")
+	flagQuiet      *bool   = flag.Bool("quiet", false, "Print minimal output to stdout")
 )
 
 func configureHelp() {
diff --git a/cmd/yamlfmt/main.go b/cmd/yamlfmt/main.go
index db6b7e5..fafbd0b 100644
--- a/cmd/yamlfmt/main.go
+++ b/cmd/yamlfmt/main.go
@@ -36,6 +36,7 @@ func run() error {
 	c := &command.Command{
 		Operation: getOperationFromFlag(),
 		Registry:  getFullRegistry(),
+		Quiet:     *flagQuiet,
 	}
 
 	configData := map[string]any{}
diff --git a/command/command.go b/command/command.go
index 14cffb4..5617067 100644
--- a/command/command.go
+++ b/command/command.go
@@ -16,8 +16,10 @@ package command
 
 import (
 	"bufio"
+	"errors"
 	"fmt"
 	"io"
+	"log"
 	"os"
 
 	"github.com/google/yamlfmt"
@@ -51,6 +53,7 @@ type Command struct {
 	Operation Operation
 	Registry  *yamlfmt.Registry
 	Config    *Config
+	Quiet     bool
 }
 
 func (c *Command) Run() error {
@@ -89,35 +92,47 @@ func (c *Command) Run() error {
 	if err != nil {
 		return err
 	}
-	engine := &engine.Engine{
+
+	eng := &engine.ConsecutiveEngine{
 		LineSepCharacter: lineSepChar,
 		Formatter:        formatter,
-		PathCollector:    c.makePathCollector(),
+		Quiet:            c.Quiet,
+	}
+
+	paths, err := c.collectPaths()
+	if err != nil {
+		return err
 	}
 
 	switch c.Operation {
 	case OperationFormat:
-		err := engine.FormatAllFiles()
+		err := eng.Format(paths)
 		if err != nil {
 			return err
 		}
 	case OperationLint:
-		err := engine.LintAllFiles()
+		out, err := eng.Lint(paths)
 		if err != nil {
 			return err
 		}
+		if out != nil {
+			// This will be picked up by log.Fatal in main() and
+			// cause an exit code of 1, which is a critical
+			// component of the lint functionality.
+			return errors.New(out.String())
+		}
 	case OperationDry:
-		out, err := engine.DryRunAllFiles()
+		out, err := eng.DryRun(paths)
 		if err != nil {
 			return err
 		}
-		fmt.Println(out)
+		log.Print(out)
 	case OperationStdin:
 		stdinYaml, err := readFromStdin()
 		if err != nil {
 			return err
 		}
-		out, err := engine.Formatter.Format(stdinYaml)
+		out, err := eng.FormatContent(stdinYaml)
 		if err != nil {
 			return err
 		}
@@ -127,6 +142,11 @@ func (c *Command) Run() error {
 	return nil
 }
 
+func (c *Command) collectPaths() ([]string, error) {
+	collector := c.makePathCollector()
+	return collector.CollectPaths()
+}
+
 func (c *Command) makePathCollector() yamlfmt.PathCollector {
 	if c.Config.Doublestar {
 		return &yamlfmt.DoublestarCollector{
diff --git a/docs/config.md b/docs/config.md
index 869d3a7..8c2ef8b 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,14 +1,25 @@
+# Configuration With .yamlfmt
 
-# Command
+## Config File Discovery
+
+The config file is discovered in the following priority order:
+
+1. Specified in the `--conf` flag (if this is an invalid path or doesn't exist, the tool will fail)
+2. A `.yamlfmt` file in the current working directory
+3. A `yamlfmt` folder with a `.yamlfmt` file in the system config directory (`$XDG_CONFIG_HOME`, `$HOME/.config`, `%LOCALAPPDATA%`) e.g. `$HOME/.config/yamlfmt/.yamlfmt`
+
+If none of these are found, the tool's default configuration will be used.
+
+## Command
 
 The command package defines the main command engine that `cmd/yamlfmt` uses. It uses the top level configuration that any run of the yamlfmt command will use.
 
-## Configuration
+### Configuration
 
 | Key                      | Type           | Default | Description |
 |:-------------------------|:---------------|:--------|:------------|
 | `line_ending`            | `lf` or `crlf` | `crlf` on Windows, `lf` otherwise | Parse and write the file with "lf" or "crlf" line endings. This global setting will override any formatter `line_ending` options. |
-| `doublestar`             | bool           | false   | Use [doublestar](https://github.com/bmatcuk/doublestar) for include and exclude paths. (This was the default before 0.8.0) |
+| `doublestar`             | bool           | false   | Use [doublestar](https://github.com/bmatcuk/doublestar) for include and exclude paths. (This was the default before 0.7.0) |
 | `include`                | []string       | []      | The paths for the command to include for formatting. See [Specifying Paths](#specifying-paths) for more details. |
 | `exclude`                | []string       | []      | The paths for the command to exclude from formatting. See [Specifying Paths](#specifying-paths) for more details. |
 | `extensions`             | []string       | []      | The extensions to use for standard mode path collection. See [Specifying Paths](#specifying-paths) for more details. |
@@ -24,7 +35,7 @@ In standard path mode, the you can specify a file or directory path directly. If
 
 In Doublestar mode, paths are specified using the format explained in the [doublestar](https://github.com/bmatcuk/doublestar) package. It is almost identical to bash and git's style of glob pattern specification.
 
-# Formatter
+## Formatter
 
 Formatter settings are specified by giving a formatter type in the `type` field, and specifying the rest of the formatter settings in the same block. For example, to get a default `basic` formatter, use the following configuration:
 ```yaml
@@ -39,11 +50,11 @@ formatter:
 ```
 Currently, there is only a `basic` formatter, however there is full support for making your own formatter in a fork or the potential for a new formatter to exist in the future.
 
-# Basic Formatter
+## Basic Formatter
 
 The basic formatter is a barebones formatter that simply takes the data provided, serializes it with a fork of [gopkg.in/yaml.v3](https://www.github.com/braydonk/yaml) and encodes it again. This provides a consistent output format that is very opinionated with some minor tweak options.
 
-## Configuration
+### Configuration
 
 | Key                      | Type           | Default | Description |
 |:-------------------------|:---------------|:--------|:------------|
@@ -55,6 +66,6 @@ The basic formatter is a barebones formatter that simply takes the data provided
 | `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 `>`). |
 
-## Note on `max_line_length`
+### Note on `max_line_length`
 
 It's not perfect; it uses the `best_width` setting from the yaml library. If there's a very long token that extends too far for the line width, it won't split it up properly. I will keep trying to make this work better, but decided to get a version of the feature in that works for a lot of scenarios even if not all of them.
diff --git a/engine.go b/engine.go
new file mode 100644
index 0000000..d7b8043
--- /dev/null
+++ b/engine.go
@@ -0,0 +1,118 @@
+package yamlfmt
+
+import (
+	"errors"
+	"fmt"
+	"os"
+
+	"github.com/RageCage64/multilinediff"
+)
+
+type Engine interface {
+	FormatContent(content []byte) ([]byte, error)
+	Format(paths []string) error
+	Lint(paths []string) (*EngineOutput, error)
+	DryRun(paths []string) (*EngineOutput, error)
+}
+
+type EngineOutput struct {
+	Message string
+	Files   FileDiffs
+	Quiet   bool
+}
+
+func (eo *EngineOutput) String() string {
+	var msg string
+	if eo.Message != "" {
+		msg += fmt.Sprintf("%s\n\n", eo.Message)
+	}
+	if eo.Quiet {
+		msg += eo.Files.StrOutputQuiet()
+	} else {
+		msg += fmt.Sprintf("%s\n", eo.Files.StrOutput())
+	}
+	return msg
+}
+
+type FormatDiff struct {
+	Original  string
+	Formatted string
+	LineSep   string
+}
+
+func (d *FormatDiff) MultilineDiff() (string, int) {
+	return multilinediff.Diff(d.Original, d.Formatted, d.LineSep)
+}
+
+func (d *FormatDiff) Changed() bool {
+	return d.Original != d.Formatted
+}
+
+type FileDiff struct {
+	Path string
+	Diff *FormatDiff
+}
+
+func (fd *FileDiff) StrOutput() string {
+	diffStr, _ := fd.Diff.MultilineDiff()
+	return fmt.Sprintf("%s:\n%s\n", fd.Path, diffStr)
+}
+
+func (fd *FileDiff) StrOutputQuiet() string {
+	return fd.Path + "\n"
+}
+
+func (fd *FileDiff) Apply() error {
+	return os.WriteFile(fd.Path, []byte(fd.Diff.Formatted), 0644)
+}
+
+type FileDiffs map[string]*FileDiff
+
+func (fds FileDiffs) Add(diff *FileDiff) error {
+	if _, ok := fds[diff.Path]; ok {
+		return fmt.Errorf("a diff for %s already exists", diff.Path)
+	}
+
+	fds[diff.Path] = diff
+	return nil
+}
+
+func (fds FileDiffs) StrOutput() string {
+	result := ""
+	for _, fd := range fds {
+		if fd.Diff.Changed() {
+			result += fd.StrOutput()
+		}
+	}
+	return result
+}
+
+func (fds FileDiffs) StrOutputQuiet() string {
+	result := ""
+	for _, fd := range fds {
+		if fd.Diff.Changed() {
+			result += fd.StrOutputQuiet()
+		}
+	}
+	return result
+}
+
+func (fds FileDiffs) ApplyAll() error {
+	applyErrs := make([]error, len(fds))
+	i := 0
+	for _, diff := range fds {
+		applyErrs[i] = diff.Apply()
+		i++
+	}
+	return errors.Join(applyErrs...)
+}
+
+func (fds FileDiffs) ChangedCount() int {
+	changed := 0
+	for _, fd := range fds {
+		if fd.Diff.Changed() {
+			changed++
+		}
+	}
+	return changed
+}
diff --git a/engine/consecutive_engine.go b/engine/consecutive_engine.go
new file mode 100644
index 0000000..23c2539
--- /dev/null
+++ b/engine/consecutive_engine.go
@@ -0,0 +1,110 @@
+// 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 (
+	"os"
+
+	"github.com/google/yamlfmt"
+)
+
+// Engine that will process each file one by one consecutively.
+type ConsecutiveEngine struct {
+	LineSepCharacter string
+	Formatter        yamlfmt.Formatter
+	Quiet            bool
+}
+
+func (e *ConsecutiveEngine) FormatContent(content []byte) ([]byte, error) {
+	return e.Formatter.Format(content)
+}
+
+func (e *ConsecutiveEngine) Format(paths []string) error {
+	formatDiffs, formatErrs := e.formatAll(paths)
+	if len(formatErrs) > 0 {
+		return formatErrs
+	}
+	return formatDiffs.ApplyAll()
+}
+
+func (e *ConsecutiveEngine) Lint(paths []string) (*yamlfmt.EngineOutput, error) {
+	formatDiffs, formatErrs := e.formatAll(paths)
+	if len(formatErrs) > 0 {
+		return nil, formatErrs
+	}
+	if formatDiffs.ChangedCount() == 0 {
+		return nil, nil
+	}
+
+	message := "The following formatting differences were found:"
+	if e.Quiet {
+		message = "The following files had formatting differences:"
+	}
+
+	return &yamlfmt.EngineOutput{
+		Message: message,
+		Files:   formatDiffs,
+		Quiet:   e.Quiet,
+	}, nil
+}
+
+func (e *ConsecutiveEngine) DryRun(paths []string) (*yamlfmt.EngineOutput, error) {
+	formatDiffs, formatErrs := e.formatAll(paths)
+	if len(formatErrs) > 0 {
+		return nil, formatErrs
+	}
+
+	output := &yamlfmt.EngineOutput{
+		Files: formatDiffs,
+		Quiet: e.Quiet,
+	}
+	if len(formatDiffs) > 0 && e.Quiet {
+		output.Message = "The following files will have formatting changes:"
+	}
+	return output, nil
+}
+
+func (e *ConsecutiveEngine) formatAll(paths []string) (yamlfmt.FileDiffs, FormatErrors) {
+	formatDiffs := yamlfmt.FileDiffs{}
+	formatErrs := FormatErrors{}
+	for _, path := range paths {
+		fileDiff, err := e.formatFileContent(path)
+		if err != nil {
+			formatErrs = append(formatErrs, wrapFormatError(path, err))
+			continue
+		}
+		formatDiffs.Add(fileDiff)
+	}
+	return formatDiffs, formatErrs
+}
+
+func (e *ConsecutiveEngine) formatFileContent(path string) (*yamlfmt.FileDiff, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	formatted, err := e.FormatContent(content)
+	if err != nil {
+		return nil, err
+	}
+	return &yamlfmt.FileDiff{
+		Path: path,
+		Diff: &yamlfmt.FormatDiff{
+			Original:  string(content),
+			Formatted: string(formatted),
+			LineSep:   e.LineSepCharacter,
+		},
+	}, nil
+}
diff --git a/engine/engine.go b/engine/engine.go
deleted file mode 100644
index 2087c8a..0000000
--- a/engine/engine.go
+++ /dev/null
@@ -1,134 +0,0 @@
-// 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"
-	"os"
-
-	"github.com/RageCage64/multilinediff"
-	"github.com/google/yamlfmt"
-)
-
-type Engine struct {
-	LineSepCharacter string
-	Formatter        yamlfmt.Formatter
-	PathCollector    yamlfmt.PathCollector
-}
-
-func (e *Engine) FormatAllFiles() error {
-	paths, err := e.PathCollector.CollectPaths()
-	if err != nil {
-		return err
-	}
-
-	formatErrors := NewFormatFileErrors()
-	for _, path := range paths {
-		err := e.FormatFile(path)
-		if err != nil {
-			formatErrors.Add(path, err)
-		}
-	}
-
-	if formatErrors.Count() > 0 {
-		return formatErrors
-	}
-	return nil
-}
-
-func (e *Engine) FormatFile(path string) error {
-	yamlBytes, err := os.ReadFile(path)
-	if err != nil {
-		return err
-	}
-	formatted, err := e.Formatter.Format(yamlBytes)
-	if err != nil {
-		return err
-	}
-	err = os.WriteFile(path, formatted, 0644)
-	return err
-}
-
-func (e *Engine) LintAllFiles() error {
-	paths, err := e.PathCollector.CollectPaths()
-	if err != nil {
-		return err
-	}
-
-	lintErrors := NewLintFileErrors()
-	for _, path := range paths {
-		err := e.LintFile(path)
-		if err != nil {
-			lintErrors.Add(path, err)
-		}
-	}
-
-	if lintErrors.Count() > 0 {
-		return lintErrors
-	}
-	return nil
-}
-
-func (e *Engine) LintFile(path string) error {
-	yamlBytes, err := os.ReadFile(path)
-	if err != nil {
-		return err
-	}
-	formatted, err := e.Formatter.Format(yamlBytes)
-	if err != nil {
-		return err
-	}
-	diff, diffCount := multilinediff.Diff(string(yamlBytes), string(formatted), e.LineSepCharacter)
-	if diffCount > 0 {
-		return fmt.Errorf(diff)
-	}
-	return nil
-}
-
-func (e *Engine) DryRunAllFiles() (string, error) {
-	paths, err := e.PathCollector.CollectPaths()
-	if err != nil {
-		return "", err
-	}
-
-	formatErrors := NewFormatFileErrors()
-	dryRunDiffs := NewDryRunDiffs()
-	for _, path := range paths {
-		diff, diffCount, err := e.DryRunFile(path)
-		if err != nil {
-			formatErrors.Add(path, err)
-		} else if diffCount > 0 {
-			dryRunDiffs.Add(path, diff)
-		}
-	}
-
-	if formatErrors.Count() > 0 {
-		return "", formatErrors
-	}
-	return dryRunDiffs.CombineOutput(), nil
-}
-
-func (e *Engine) DryRunFile(path string) (string, int, error) {
-	yamlBytes, err := os.ReadFile(path)
-	if err != nil {
-		return "", 0, err
-	}
-	formatted, err := e.Formatter.Format(yamlBytes)
-	if err != nil {
-		return "", 0, err
-	}
-	diff, diffCount := multilinediff.Diff(string(yamlBytes), string(formatted), e.LineSepCharacter)
-	return diff, diffCount, nil
-}
diff --git a/engine/errors.go b/engine/errors.go
index 77123fa..27dbba1 100644
--- a/engine/errors.go
+++ b/engine/errors.go
@@ -16,54 +16,25 @@ package engine
 
 import "fmt"
 
-type FormatFileErrors struct {
-	formatErrors map[string]error
-}
-
-func NewFormatFileErrors() *FormatFileErrors {
-	return &FormatFileErrors{
-		formatErrors: map[string]error{},
-	}
-}
+type FormatErrors []*FormatError
 
-func (e *FormatFileErrors) Error() string {
-	errStr := "encountered the following formatting errors:"
-	for file, err := range e.formatErrors {
-		errStr += fmt.Sprintf("%s:%v,", file, err)
+func (e FormatErrors) Error() string {
+	errStr := "encountered the following formatting errors:\n"
+	for _, err := range e {
+		errStr += fmt.Sprintf("%s\n", err.Error())
 	}
 	return errStr
 }
 
-func (e *FormatFileErrors) Add(file string, err error) {
-	e.formatErrors[file] = err
-}
-
-func (e *FormatFileErrors) Count() int {
-	return len(e.formatErrors)
-}
-
-type LintFileErrors struct {
-	lintErrors map[string]error
-}
-
-func NewLintFileErrors() *LintFileErrors {
-	return &LintFileErrors{
-		lintErrors: map[string]error{},
-	}
-}
-
-func (e *LintFileErrors) Error() string {
-	errStr := "encountered the following linting errors:\n"
-	for file, err := range e.lintErrors {
-		errStr += fmt.Sprintf("%s:\n%v\n", file, err)
-	}
-	return errStr
+type FormatError struct {
+	path string
+	err  error
 }
 
-func (e *LintFileErrors) Add(file string, err error) {
-	e.lintErrors[file] = err
+func wrapFormatError(path string, err error) *FormatError {
+	return &FormatError{path: path, err: err}
 }
 
-func (e *LintFileErrors) Count() int {
-	return len(e.lintErrors)
+func (e *FormatError) Error() string {
+	return fmt.Sprintf("%s: %v", e.path, e.err)
 }
diff --git a/engine/util.go b/engine/util.go
deleted file mode 100644
index 0315e3f..0000000
--- a/engine/util.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// 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"
-
-type DryRunDiffs struct {
-	diffs map[string]string
-}
-
-func NewDryRunDiffs() *DryRunDiffs {
-	return &DryRunDiffs{diffs: map[string]string{}}
-}
-
-func (d *DryRunDiffs) Add(path, diff string) {
-	d.diffs[path] = diff
-}
-
-func (d *DryRunDiffs) CombineOutput() string {
-	if len(d.diffs) == 0 {
-		return "dry run produced no output"
-	}
-
-	s := ""
-	for path, diff := range d.diffs {
-		s += fmt.Sprintf("%s:\n%s\n", path, diff)
-	}
-	return s
-}
diff --git a/formatters/basic/features.go b/formatters/basic/features.go
index 0acbd71..d7527f6 100644
--- a/formatters/basic/features.go
+++ b/formatters/basic/features.go
@@ -15,8 +15,9 @@
 package basic
 
 import (
+	"github.com/braydonk/yaml"
 	"github.com/google/yamlfmt"
-	"github.com/google/yamlfmt/internal/anchors"
+	"github.com/google/yamlfmt/formatters/basic/anchors"
 	"github.com/google/yamlfmt/internal/hotfix"
 )
 
@@ -33,6 +34,20 @@ func ConfigureFeaturesFromConfig(config *Config) yamlfmt.FeatureList {
 	return features
 }
 
+// These features will directly use the `yaml.Node` type and
+// as such are specific to this formatter.
+type YAMLFeatureFunc func(yaml.Node) error
+type YAMLFeatureList []YAMLFeatureFunc
+
+func (fl YAMLFeatureList) ApplyFeatures(node yaml.Node) error {
+	for _, f := range fl {
+		if err := f(node); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func ConfigureYAMLFeaturesFromConfig(config *Config) YAMLFeatureList {
 	var features YAMLFeatureList
 	if config.DisallowAnchors {
diff --git a/formatters/basic/formatter.go b/formatters/basic/formatter.go
index 6003509..feb59d5 100644
--- a/formatters/basic/formatter.go
+++ b/formatters/basic/formatter.go
@@ -110,16 +110,3 @@ func (f *BasicFormatter) getNewEncoder(buf *bytes.Buffer) *yaml.Encoder {
 	}
 	return e
 }
-
-type YAMLFeatureList []YAMLFeatureFunc
-
-func (fl YAMLFeatureList) ApplyFeatures(node yaml.Node) error {
-	for _, f := range fl {
-		if err := f(node); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-type YAMLFeatureFunc func(yaml.Node) error
diff --git a/go.mod b/go.mod
index a3bfceb..d4cd36b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/google/yamlfmt
 
-go 1.19
+go 1.20
 
 require (
 	github.com/RageCage64/multilinediff v0.2.0
diff --git a/internal/tempfile/path.go b/internal/tempfile/path.go
new file mode 100644
index 0000000..f8a3a51
--- /dev/null
+++ b/internal/tempfile/path.go
@@ -0,0 +1,27 @@
+package tempfile
+
+import (
+	"os"
+	"path/filepath"
+)
+
+type Path struct {
+	BasePath string
+	FileName string
+	IsDir    bool
+}
+
+func (p *Path) Create() error {
+	file := p.fullPath()
+	var err error
+	if p.IsDir {
+		err = os.Mkdir(file, os.ModePerm)
+	} else {
+		err = os.WriteFile(file, []byte{}, os.ModePerm)
+	}
+	return err
+}
+
+func (p *Path) fullPath() string {
+	return filepath.Join(p.BasePath, p.FileName)
+}


Test patch:
diff --git a/internal/anchors/check.go b/formatters/basic/anchors/check.go
similarity index 100%
rename from internal/anchors/check.go
rename to formatters/basic/anchors/check.go
diff --git a/internal/anchors/check_test.go b/formatters/basic/anchors/check_test.go
similarity index 96%
rename from internal/anchors/check_test.go
rename to formatters/basic/anchors/check_test.go
index 8d8071a..0cbf645 100644
--- a/internal/anchors/check_test.go
+++ b/formatters/basic/anchors/check_test.go
@@ -19,7 +19,7 @@ import (
 	"testing"
 
 	"github.com/braydonk/yaml"
-	"github.com/google/yamlfmt/internal/anchors"
+	"github.com/google/yamlfmt/formatters/basic/anchors"
 )
 
 func TestCheck(t *testing.T) {
diff --git a/collector.go b/path_collector.go
similarity index 100%
rename from collector.go
rename to path_collector.go
diff --git a/collector_test.go b/path_collector_test.go
similarity index 72%
rename from collector_test.go
rename to path_collector_test.go
index 2d7caa3..d4e1629 100644
--- a/collector_test.go
+++ b/path_collector_test.go
@@ -2,39 +2,18 @@ package yamlfmt_test
 
 import (
 	"fmt"
-	"os"
 	"path/filepath"
 	"reflect"
 	"testing"
 
 	"github.com/google/yamlfmt"
+	"github.com/google/yamlfmt/internal/tempfile"
 )
 
-type dummyPath struct {
-	basePath string
-	filename string
-	isDir    bool
-}
-
-func (p *dummyPath) Create() error {
-	file := p.fullPath()
-	var err error
-	if p.isDir {
-		err = os.Mkdir(file, os.ModePerm)
-	} else {
-		err = os.WriteFile(file, []byte{}, os.ModePerm)
-	}
-	return err
-}
-
-func (p *dummyPath) fullPath() string {
-	return filepath.Join(p.basePath, p.filename)
-}
-
 func TestCollectPaths(t *testing.T) {
 	testCases := []struct {
 		name            string
-		files           []dummyPath
+		files           []tempfile.Path
 		includePatterns []string
 		excludePatterns []string
 		extensions      []string
@@ -42,10 +21,10 @@ func TestCollectPaths(t *testing.T) {
 	}{
 		{
 			name: "finds direct paths",
-			files: []dummyPath{
-				{filename: "x.yaml"},
-				{filename: "y.yaml"},
-				{filename: "z.yml"},
+			files: []tempfile.Path{
+				{FileName: "x.yaml"},
+				{FileName: "y.yaml"},
+				{FileName: "z.yml"},
 			},
 			includePatterns: []string{
 				"x.yaml",
@@ -60,11 +39,11 @@ func TestCollectPaths(t *testing.T) {
 		},
 		{
 			name: "finds all in directory one layer",
-			files: []dummyPath{
-				{filename: "a", isDir: true},
-				{filename: "a/x.yaml"},
-				{filename: "a/y.yaml"},
-				{filename: "a/z.yml"},
+			files: []tempfile.Path{
+				{FileName: "a", IsDir: true},
+				{FileName: "a/x.yaml"},
+				{FileName: "a/y.yaml"},
+				{FileName: "a/z.yml"},
 			},
 			includePatterns: []string{
 				"a",
@@ -81,11 +60,11 @@ func TestCollectPaths(t *testing.T) {
 		},
 		{
 			name: "finds direct path to subdirectory",
-			files: []dummyPath{
-				{filename: "a", isDir: true},
-				{filename: "a/x.yaml"},
-				{filename: "a/y.yaml"},
-				{filename: "a/z.yml"},
+			files: []tempfile.Path{
+				{FileName: "a", IsDir: true},
+				{FileName: "a/x.yaml"},
+				{FileName: "a/y.yaml"},
+				{FileName: "a/z.yml"},
 			},
 			includePatterns: []string{
 				"a/x.yaml",
@@ -98,15 +77,15 @@ func TestCollectPaths(t *testing.T) {
 		},
 		{
 			name: "finds all in layered directories",
-			files: []dummyPath{
-				{filename: "a", isDir: true},
-				{filename: "a/b", isDir: true},
-				{filename: "x.yml"},
-				{filename: "y.yml"},
-				{filename: "z.yaml"},
-				{filename: "a/x.yaml"},
-				{filename: "a/b/x.yaml"},
-				{filename: "a/b/y.yml"},
+			files: []tempfile.Path{
+				{FileName: "a", IsDir: true},
+				{FileName: "a/b", IsDir: true},
+				{FileName: "x.yml"},
+				{FileName: "y.yml"},
+				{FileName: "z.yaml"},
+				{FileName: "a/x.yaml"},
+				{FileName: "a/b/x.yaml"},
+				{FileName: "a/b/y.yml"},
 			},
 			includePatterns: []string{
 				"", // with the test this functionally means the whole temp dir
@@ -126,15 +105,15 @@ func TestCollectPaths(t *testing.T) {
 		},
 		{
 			name: "exclude files",
-			files: []dummyPath{
-				{filename: "a", isDir: true},
-				{filename: "a/b", isDir: true},
-				{filename: "x.yml"},
-				{filename: "y.yml"},
-				{filename: "z.yaml"},
-				{filename: "a/x.yaml"},
-				{filename: "a/b/x.yaml"},
-				{filename: "a/b/y.yml"},
+			files: []tempfile.Path{
+				{FileName: "a", IsDir: true},
+				{FileName: "a/b", IsDir: true},
+				{FileName: "x.yml"},
+				{FileName: "y.yml"},
+				{FileName: "z.yaml"},
+				{FileName: "a/x.yaml"},
+				{FileName: "a/b/x.yaml"},
+				{FileName: "a/b/y.yml"},
 			},
 			includePatterns: []string{
 				"", // with the test this functionally means the whole temp dir
@@ -156,15 +135,15 @@ func TestCollectPaths(t *testing.T) {
 		},
 		{
 			name: "exclude directory",
-			files: []dummyPath{
-				{filename: "a", isDir: true},
-				{filename: "a/b", isDir: true},
-				{filename: "x.yml"},
-				{filename: "y.yml"},
-				{filename: "z.yaml"},
-				{filename: "a/x.yaml"},
-				{filename: "a/b/x.yaml"},
-				{filename: "a/b/y.yml"},
+			files: []tempfile.Path{
+				{FileName: "a", IsDir: true},
+				{FileName: "a/b", IsDir: true},
+				{FileName: "x.yml"},
+				{FileName: "y.yml"},
+				{FileName: "z.yaml"},
+				{FileName: "a/x.yaml"},
+				{FileName: "a/b/x.yaml"},
+				{FileName: "a/b/y.yml"},
 			},
 			includePatterns: []string{
 				"", // with the test this functionally means the whole temp dir
@@ -192,9 +171,9 @@ func TestCollectPaths(t *testing.T) {
 			tempPath := t.TempDir()
 
 			for _, file := range tc.files {
-				file.basePath = tempPath
+				file.BasePath = tempPath
 				if err := file.Create(); err != nil {
-					t.Fatalf("Failed to create file %s: %v", file.basePath, err)
+					t.Fatalf("Failed to create file %s: %v", file.BasePath, err)
 				}
 			}
 
@@ -231,17 +210,17 @@ func TestDoublestarCollectPaths(t *testing.T) {
 	t.Skip()
 	testCases := []struct {
 		name            string
-		files           []dummyPath
+		files           []tempfile.Path
 		includePatterns []string
 		excludePatterns []string
 		expectedFiles   map[string]struct{}
 	}{
 		{
 			name: "no excludes",
-			files: []dummyPath{
-				{filename: "x.yaml"},
-				{filename: "y.yaml"},
-				{filename: "z.yaml"},
+			files: []tempfile.Path{
+				{FileName: "x.yaml"},
+				{FileName: "y.yaml"},
+				{FileName: "z.yaml"},
 			},
 			expectedFiles: map[string]struct{}{
 				"x.yaml": {},
@@ -251,9 +230,9 @@ func TestDoublestarCollectPaths(t *testing.T) {
 		},
 		{
 			name: "does not include directories",
-			files: []dummyPath{
-				{filename: "x.yaml"},
-				{filename: "y", isDir: true},
+			files: []tempfile.Path{
+				{FileName: "x.yaml"},
+				{FileName: "y", IsDir: true},
 			},
 			expectedFiles: map[string]struct{}{
 				"x.yaml": {},
@@ -261,9 +240,9 @@ func TestDoublestarCollectPaths(t *testing.T) {
 		},
 		{
 			name: "only include what is asked",
-			files: []dummyPath{
-				{filename: "x.yaml"},
-				{filename: "y.yaml"},
+			files: []tempfile.Path{
+				{FileName: "x.yaml"},
+				{FileName: "y.yaml"},
 			},
 			includePatterns: []string{
 				"y.yaml",
@@ -274,9 +253,9 @@ func TestDoublestarCollectPaths(t *testing.T) {
 		},
 		{
 			name: "exclude what is asked",
-			files: []dummyPath{
-				{filename: "x.yaml"},
-				{filename: "y.yaml"},
+			files: []tempfile.Path{
+				{FileName: "x.yaml"},
+				{FileName: "y.yaml"},
 			},
 			excludePatterns: []string{
 				"y.yaml",
@@ -294,7 +273,7 @@ func TestDoublestarCollectPaths(t *testing.T) {
 			tempPath := t.TempDir()
 
 			for _, file := range tc.files {
-				file.basePath = tempPath
+				file.BasePath = tempPath
 				if err := file.Create(); err != nil {
 					t.Fatalf("Failed to create file")
 				}