[cli] Add `shac fmt` command

Identical to `shac fix`, but only runs and applies fixes from checks
annotated with `formatter = True`.

Change-Id: Ic0c963203dc098b1618fb7f6e1f8b165dd444959
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/881734
Commit-Queue: Oliver Newman <olivernewman@google.com>
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
diff --git a/internal/cli/fix.go b/internal/cli/fix.go
index d333d1f..064b48a 100644
--- a/internal/cli/fix.go
+++ b/internal/cli/fix.go
@@ -31,7 +31,7 @@
 }
 
 func (*fixCmd) Description() string {
-	return "Run checks and make suggested fixes."
+	return "Run non-formatter checks and make suggested fixes."
 }
 
 func (c *fixCmd) SetFlags(f *flag.FlagSet) {
@@ -43,5 +43,6 @@
 		return errors.New("unsupported arguments")
 	}
 	o := c.options()
+	o.Filter = engine.OnlyNonFormatters
 	return engine.Fix(ctx, &o)
 }
diff --git a/internal/cli/fmt.go b/internal/cli/fmt.go
new file mode 100644
index 0000000..019249b
--- /dev/null
+++ b/internal/cli/fmt.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Shac Authors
+//
+// 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 cli
+
+import (
+	"context"
+	"errors"
+
+	flag "github.com/spf13/pflag"
+	"go.fuchsia.dev/shac-project/shac/internal/engine"
+)
+
+type fmtCmd struct {
+	commandBase
+}
+
+func (*fmtCmd) Name() string {
+	return "fmt"
+}
+
+func (*fmtCmd) Description() string {
+	return "Auto-format files."
+}
+
+func (c *fmtCmd) SetFlags(f *flag.FlagSet) {
+	c.commandBase.SetFlags(f)
+}
+
+func (c *fmtCmd) Execute(ctx context.Context, args []string) error {
+	if len(args) != 0 {
+		return errors.New("unsupported arguments")
+	}
+	o := c.options()
+	o.Filter = engine.OnlyFormatters
+	return engine.Fix(ctx, &o)
+}
diff --git a/internal/cli/main.go b/internal/cli/main.go
index f4c2e19..115de4c 100644
--- a/internal/cli/main.go
+++ b/internal/cli/main.go
@@ -68,7 +68,10 @@
 	ctx := context.Background()
 
 	subcommands := [...]subcommand{
+		// Ordered roughly by importance, because ordering here corresponds to
+		// the order in which subcommands will be listed in `shac help`.
 		&checkCmd{},
+		&fmtCmd{},
 		&fixCmd{},
 		&docCmd{},
 		&helpCmd{},
diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go
index e744b96..9c2afb8 100644
--- a/internal/cli/main_test.go
+++ b/internal/cli/main_test.go
@@ -31,6 +31,7 @@
 		{[]string{"shac", "--help"}, "Usage of shac:\n"},
 		{[]string{"shac", "check", "--help"}, "Usage of shac check:\n"},
 		{[]string{"shac", "fix", "--help"}, "Usage of shac fix:\n"},
+		{[]string{"shac", "fmt", "--help"}, "Usage of shac fmt:\n"},
 		{[]string{"shac", "doc", "--help"}, "Usage of shac doc:\n"},
 	}
 	for i, line := range data {
diff --git a/internal/engine/run.go b/internal/engine/run.go
index b801600..7832c95 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -71,6 +71,21 @@
 	_ struct{}
 }
 
+// CheckFilter controls which checks get run by `Run`. It returns true for
+// checks that should be run, false for checks that should be skipped.
+type CheckFilter func(registeredCheck) bool
+
+// OnlyFormatters causes only checks marked with `formatter = True` to be run.
+func OnlyFormatters(c registeredCheck) bool {
+	return c.formatter
+}
+
+// OnlyNonFormatters causes only checks *not* marked with `formatter = True` to
+// be run.
+func OnlyNonFormatters(c registeredCheck) bool {
+	return !c.formatter
+}
+
 // Level is one of "notice", "warning" or "error".
 //
 // A check is only considered failed if it emits at least one finding with
@@ -132,6 +147,8 @@
 	AllFiles bool
 	// Recurse tells the engine to run all Main files found in subdirectories.
 	Recurse bool
+	// Filter controls which checks run.
+	Filter CheckFilter
 
 	// main source file to run. Defaults to shac.star. Only used in unit tests.
 	main string
@@ -201,14 +218,14 @@
 	if err != nil {
 		return err
 	}
-	err = runInner(ctx, root, tmpdir, main, o.Report, doc.AllowNetwork, doc.WritableRoot, o.Recurse, scm, packages)
+	err = runInner(ctx, root, tmpdir, main, o.Report, doc.AllowNetwork, doc.WritableRoot, o.Recurse, o.Filter, scm, packages)
 	if err2 := os.RemoveAll(tmpdir); err == nil {
 		err = err2
 	}
 	return err
 }
 
-func runInner(ctx context.Context, root, tmpdir, main string, r Report, allowNetwork, writableRoot, recurse bool, scm scmCheckout, packages map[string]fs.FS) error {
+func runInner(ctx context.Context, root, tmpdir, main string, r Report, allowNetwork, writableRoot, recurse bool, filter CheckFilter, scm scmCheckout, packages map[string]fs.FS) error {
 	sb, err := sandbox.New(tmpdir)
 	if err != nil {
 		return err
@@ -244,6 +261,7 @@
 						r:            r,
 						allowNetwork: allowNetwork,
 						writableRoot: writableRoot,
+						filter:       filter,
 						main:         main,
 						root:         root,
 						subdir:       d,
@@ -263,6 +281,7 @@
 				r:            r,
 				allowNetwork: allowNetwork,
 				writableRoot: writableRoot,
+				filter:       filter,
 				main:         main,
 				root:         root,
 				tmpdir:       filepath.Join(tmpdir, "0"),
@@ -350,6 +369,8 @@
 	// Checks are executed sequentially after all Starlark code is loaded and not
 	// mutated. They run checks and emit results (results and comments).
 	checks []registeredCheck
+	// filter controls which checks run. If nil, all checks will run.
+	filter CheckFilter
 
 	// Set when fail() is called. This happens only during the first phase, thus
 	// no mutex is needed.
@@ -415,6 +436,9 @@
 	args := starlark.Tuple{getCtx(path.Join(s.root, s.subdir))}
 	args.Freeze()
 	for i := range s.checks {
+		if s.filter != nil && !s.filter(s.checks[i]) {
+			continue
+		}
 		i := i
 		ch <- func() error {
 			start := time.Now()