[engine] Support analyzing only specified files

Users can now run `shac {check,fix,fmt} file1 file2 file3 ...` to run
checks only on the specified list of files, regardless of which files
are affected according to the SCM.

Checks that explicitly call `all_files()` will still see all files.

Change-Id: Ife998392327438a1fae443e477e0b20b5f8490a8
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/904638
Commit-Queue: Marc-Antoine Ruel <maruel@google.com>
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
diff --git a/internal/cli/base.go b/internal/cli/base.go
index 3ad981c..929e908 100644
--- a/internal/cli/base.go
+++ b/internal/cli/base.go
@@ -15,6 +15,8 @@
 package cli
 
 import (
+	"errors"
+
 	flag "github.com/spf13/pflag"
 	"go.fuchsia.dev/shac-project/shac/internal/engine"
 )
@@ -31,10 +33,14 @@
 	f.BoolVar(&c.noRecurse, "no-recurse", false, "do not look for shac.star files recursively")
 }
 
-func (c *commandBase) options() engine.Options {
+func (c *commandBase) options(files []string) (engine.Options, error) {
+	if c.allFiles && len(files) > 0 {
+		return engine.Options{}, errors.New("--all cannot be set together with positional file arguments")
+	}
 	return engine.Options{
 		Root:     c.root,
 		AllFiles: c.allFiles,
+		Files:    files,
 		Recurse:  !c.noRecurse,
-	}
+	}, nil
 }
diff --git a/internal/cli/check.go b/internal/cli/check.go
index d82f1f9..b88e5c7 100644
--- a/internal/cli/check.go
+++ b/internal/cli/check.go
@@ -17,7 +17,6 @@
 import (
 	"bytes"
 	"context"
-	"errors"
 	"os"
 
 	flag "github.com/spf13/pflag"
@@ -43,11 +42,7 @@
 	f.StringVar(&c.jsonOutput, "json-output", "", "path to write SARIF output to")
 }
 
-func (c *checkCmd) Execute(ctx context.Context, args []string) error {
-	if len(args) != 0 {
-		return errors.New("unsupported arguments")
-	}
-
+func (c *checkCmd) Execute(ctx context.Context, files []string) error {
 	var buf bytes.Buffer
 
 	r, err := reporting.Get(ctx)
@@ -56,7 +51,10 @@
 	}
 	r.Reporters = append(r.Reporters, &reporting.SarifReport{Out: &buf})
 
-	o := c.options()
+	o, err := c.options(files)
+	if err != nil {
+		return err
+	}
 	o.Report = r
 
 	err = engine.Run(ctx, &o)
diff --git a/internal/cli/fix.go b/internal/cli/fix.go
index 064b48a..5ceb9e9 100644
--- a/internal/cli/fix.go
+++ b/internal/cli/fix.go
@@ -16,7 +16,6 @@
 
 import (
 	"context"
-	"errors"
 
 	flag "github.com/spf13/pflag"
 	"go.fuchsia.dev/shac-project/shac/internal/engine"
@@ -38,11 +37,11 @@
 	c.commandBase.SetFlags(f)
 }
 
-func (c *fixCmd) Execute(ctx context.Context, args []string) error {
-	if len(args) != 0 {
-		return errors.New("unsupported arguments")
+func (c *fixCmd) Execute(ctx context.Context, files []string) error {
+	o, err := c.options(files)
+	if err != nil {
+		return err
 	}
-	o := c.options()
 	o.Filter = engine.OnlyNonFormatters
 	return engine.Fix(ctx, &o)
 }
diff --git a/internal/cli/fmt.go b/internal/cli/fmt.go
index 019249b..37fefaa 100644
--- a/internal/cli/fmt.go
+++ b/internal/cli/fmt.go
@@ -16,7 +16,6 @@
 
 import (
 	"context"
-	"errors"
 
 	flag "github.com/spf13/pflag"
 	"go.fuchsia.dev/shac-project/shac/internal/engine"
@@ -38,11 +37,11 @@
 	c.commandBase.SetFlags(f)
 }
 
-func (c *fmtCmd) Execute(ctx context.Context, args []string) error {
-	if len(args) != 0 {
-		return errors.New("unsupported arguments")
+func (c *fmtCmd) Execute(ctx context.Context, files []string) error {
+	o, err := c.options(files)
+	if err != nil {
+		return err
 	}
-	o := c.options()
 	o.Filter = engine.OnlyFormatters
 	return engine.Fix(ctx, &o)
 }
diff --git a/internal/engine/run.go b/internal/engine/run.go
index 91f4de2..66a71ff 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -143,6 +143,8 @@
 	Report Report
 	// Root directory. Defaults to the current working directory.
 	Root string
+	// Files lists specific files to analyze.
+	Files []string
 	// AllFiles tells to consider all files as affected.
 	AllFiles bool
 	// Recurse tells the engine to run all Main files found in subdirectories.
@@ -204,6 +206,7 @@
 	}
 	scm = &cachingSCM{
 		scm: &filteredSCM{
+			files:   o.Files,
 			matcher: gitignore.NewMatcher(patterns),
 			scm:     scm,
 		},
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 3c187b6..7c37ef0 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -116,6 +116,73 @@
 	}
 }
 
+func TestRun_SpecificFiles(t *testing.T) {
+	t.Parallel()
+	root := t.TempDir()
+	writeFile(t, root, "shac.textproto", prototext.Format(&Document{
+		Ignore: []string{
+			"*.py",
+		},
+	}))
+
+	writeFile(t, root, "python.py", "a python file")
+	writeFile(t, root, "rust.rs", "a rust file")
+
+	copySCM(t, root)
+
+	data := []struct {
+		name  string
+		want  string
+		files []string
+	}{
+		{
+			"ctx-scm-affected_files.star",
+			"[//ctx-scm-affected_files.star:19] \n" +
+				scmStarlarkFiles("") +
+				"rust.rs: \n" +
+				"shac.textproto: \n" +
+				"\n",
+			nil,
+		},
+		{
+			"ctx-scm-all_files.star",
+			"[//ctx-scm-all_files.star:19] \n" +
+				scmStarlarkFiles("") +
+				"rust.rs: \n" +
+				"shac.textproto: \n" +
+				"\n",
+			nil,
+		},
+	}
+	for i := range data {
+		i := i
+		t.Run(data[i].name, func(t *testing.T) {
+			t.Parallel()
+			testStarlarkPrint(t, root, data[i].name, false, data[i].want)
+		})
+	}
+
+	t.Run("empty ignore field", func(t *testing.T) {
+		t.Parallel()
+		root := t.TempDir()
+		writeFile(t, root, "shac.textproto", prototext.Format(&Document{
+			Ignore: []string{
+				"*.foo",
+				"",
+			},
+		}))
+
+		r := reportPrint{reportNoPrint: reportNoPrint{t: t}}
+		o := Options{Report: &r, Root: root, AllFiles: false, main: "shac.star"}
+		err := Run(context.Background(), &o)
+		if err == nil {
+			t.Fatal("Expected empty ignore field to be rejected")
+		} else if !errors.Is(err, errEmptyIgnore) {
+			t.Fatalf("Expected error %q, got %q", errEmptyIgnore, err)
+		}
+	})
+}
+
 func TestRun_Ignore(t *testing.T) {
 	t.Parallel()
 	root := t.TempDir()
@@ -207,11 +274,7 @@
 		t.Parallel()
 		want := "[//ctx-scm-affected_files.star:19] \n" +
 			"a.txt: \n" +
-			"ctx-scm-affected_files-include_deleted.star: \n" +
-			"ctx-scm-affected_files-new_lines.star: \n" +
-			"ctx-scm-affected_files.star: \n" +
-			"ctx-scm-all_files-include_deleted.star: \n" +
-			"ctx-scm-all_files.star: \n" +
+			scmStarlarkFiles("") +
 			"\n"
 		testStarlarkPrint(t, root, "ctx-scm-affected_files.star", false, want)
 	})
@@ -225,22 +288,21 @@
 		t.Parallel()
 		want := "[//ctx-scm-all_files.star:19] \n" +
 			"a.txt: \n" +
-			"ctx-scm-affected_files-include_deleted.star: \n" +
-			"ctx-scm-affected_files-new_lines.star: \n" +
-			"ctx-scm-affected_files.star: \n" +
-			"ctx-scm-all_files-include_deleted.star: \n" +
-			"ctx-scm-all_files.star: \n" +
+			scmStarlarkFiles("") +
 			"\n"
 		testStarlarkPrint(t, root, "ctx-scm-all_files.star", false, want)
 	})
 }
 
-const scmStarlarkAddedFiles = "" +
-	"ctx-scm-affected_files-include_deleted.star: A\n" +
-	"ctx-scm-affected_files-new_lines.star: A\n" +
-	"ctx-scm-affected_files.star: A\n" +
-	"ctx-scm-all_files-include_deleted.star: A\n" +
-	"ctx-scm-all_files.star: A\n"
+func scmStarlarkFiles(action string) string {
+	return fmt.Sprintf("" +
+		"ctx-scm-affected_files-include_deleted.star: " + action + "\n" +
+		"ctx-scm-affected_files-new_lines.star: " + action + "\n" +
+		"ctx-scm-affected_files.star: " + action + "\n" +
+		"ctx-scm-all_files-include_deleted.star: " + action + "\n" +
+		"ctx-scm-all_files.star: " + action + "\n",
+	)
+}
 
 func TestRun_SCM_Git_NoUpstream_Pristine(t *testing.T) {
 	// No upstream branch set, pristine checkout.
@@ -259,7 +321,7 @@
 			"ctx-scm-affected_files.star",
 			false,
 			"[//ctx-scm-affected_files.star:19] \n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"\n",
 		},
 		{
@@ -267,7 +329,7 @@
 			true,
 			"[//ctx-scm-affected_files.star:19] \n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -276,7 +338,7 @@
 			false,
 			"[//ctx-scm-all_files.star:19] \n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -310,7 +372,7 @@
 			"ctx-scm-affected_files.star",
 			false,
 			"[//ctx-scm-affected_files.star:19] \n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"\n",
 		},
 		{
@@ -330,7 +392,7 @@
 			false,
 			"[//ctx-scm-all_files.star:19] \n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -366,7 +428,7 @@
 			"ctx-scm-affected_files.star",
 			"[//ctx-scm-affected_files.star:19] \n" +
 				"a.txt: R\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -374,7 +436,7 @@
 			"ctx-scm-all_files.star",
 			"[//ctx-scm-all_files.star:19] \n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -484,7 +546,7 @@
 			"ctx-scm-affected_files.star",
 			"[//ctx-scm-affected_files.star:19] \n" +
 				".gitmodules: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"\n",
 		},
 		{
@@ -492,7 +554,7 @@
 			"[//ctx-scm-all_files.star:19] \n" +
 				".gitmodules: A\n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -542,7 +604,7 @@
 			"ctx-scm-all_files.star",
 			"[//ctx-scm-all_files.star:19] \n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n",
 		},
@@ -551,13 +613,13 @@
 			"[//ctx-scm-all_files-include_deleted.star:26] \n" +
 				"With deleted:\n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"file-to-delete.txt: D\n" +
 				"z.txt: A\n" +
 				"\n" +
 				"Without deleted:\n" +
 				"a.txt: A\n" +
-				scmStarlarkAddedFiles +
+				scmStarlarkFiles("A") +
 				"z.txt: A\n" +
 				"\n"},
 	}
diff --git a/internal/engine/runtime_ctx_scm.go b/internal/engine/runtime_ctx_scm.go
index 7f637e0..58edb9a 100644
--- a/internal/engine/runtime_ctx_scm.go
+++ b/internal/engine/runtime_ctx_scm.go
@@ -129,11 +129,20 @@
 }
 
 type filteredSCM struct {
+	// files lists specific files that should be force-included.
+	files   []string
 	matcher gitignore.Matcher
 	scm     scmCheckout
 }
 
 func (f *filteredSCM) affectedFiles(ctx context.Context, includeDeleted bool) ([]file, error) {
+	if len(f.files) > 0 {
+		var res []file
+		for _, path := range f.files {
+			res = append(res, &fileImpl{path: filepath.ToSlash(path)})
+		}
+		return res, nil
+	}
 	files, err := f.scm.affectedFiles(ctx, includeDeleted)
 	return f.filter(files), err
 }