Adding function to execute Buildozer commands on a single in-memory file (#1359)

A buildozer client are looking to execute the commands of buildozer on the content of a build file, in a service environment (without direct access to the file storage). The new function enables this by running the commands without accessing the file system.

Co-authored-by: Tim Malmström <oreflow@google.com>
diff --git a/buildozer/README.md b/buildozer/README.md
index 682108d..9d259e4 100644
--- a/buildozer/README.md
+++ b/buildozer/README.md
@@ -352,6 +352,25 @@
 add deps //base //strings|-:foo|-:bar
 ```
 
+## Using Buildozer in-memory
+
+Some clients of Buildozer have the need to execute buildozer actions in memory
+(due to a service environment which does not have access to their file system).
+This can be done using, `edit.ExecuteCommandsOnInlineFile`, which accepts
+commands and BUILD file content as bytes, applies the changes and returns the
+raw file content.
+For more details and implementation, see [`/edit/buildozer.go`](../edit/buildozer.go)
+
+Some caveats of running Buildozer in-memory:
+
+* The function assumes (and validates to some extent) that all commands apply to
+  the same file and will return errors if there are commands affecting different
+  paths.
+* When referencing targets, the function will not reliably determine if targets
+  are local or remote. Hence redundant path references may be included in
+  output. (e.g. `add dep //package/path:bar|//package/path:foo` would add the dep
+  `//package/path:bar` instead of just `:bar`).
+
 ## Error code
 
 The return code is:
diff --git a/edit/buildozer.go b/edit/buildozer.go
index c839204..9227878 100644
--- a/edit/buildozer.go
+++ b/edit/buildozer.go
@@ -1216,11 +1216,9 @@
 	}
 	var errs []error
 	changed := false
-	for _, commands := range commandsForFile.commands {
-		target := commands.target
-		commands := commands.commands
-		_, _, absPkg, rule := InterpretLabelForWorkspaceLocation(opts.RootDir, target)
-		if label := labels.Parse(target); label.Package == stdinPackageName {
+	for _, cft := range commandsForFile.commands {
+		_, _, absPkg, rule := InterpretLabelForWorkspaceLocation(opts.RootDir, cft.target)
+		if label := labels.Parse(cft.target); label.Package == stdinPackageName {
 			// Special-case: This is already absolute
 			absPkg = stdinPackageName
 		}
@@ -1231,48 +1229,27 @@
 
 		targets, err := expandTargets(f, rule)
 		if err != nil {
-			cerr := commandError(commands, target, err)
+			cerr := commandError(cft.commands, cft.target, err)
 			errs = append(errs, cerr)
 			if !opts.KeepGoing {
 				return &rewriteResult{file: name, errs: errs, records: records}
 			}
 		}
 		targets = filterRules(opts, targets)
-		for _, cmd := range commands {
-			cmdInfo := AllCommands[cmd.tokens[0]]
-			// Depending on whether a transformation is rule-specific or not, it should be applied to
-			// every rule that satisfies the filter or just once to the file.
-			cmdTargets := targets
-			if !cmdInfo.PerRule {
-				cmdTargets = []*build.Rule{nil}
-			}
-			for _, r := range cmdTargets {
-				record := &apipb.Output_Record{}
-				newf, err := cmdInfo.Fn(opts, CmdEnvironment{f, r, vars, absPkg, cmd.tokens[1:], record})
-				if len(record.Fields) != 0 {
-					records = append(records, record)
-				}
-				if err != nil {
-					cerr := commandError([]command{cmd}, target, err)
-					if opts.KeepGoing {
-						errs = append(errs, cerr)
-					} else {
-						return &rewriteResult{file: name, errs: []error{cerr}, records: records}
-					}
-				}
-				if newf != nil {
-					changed = true
-					f = newf
-				}
-			}
+
+		newf, err := executeCommandsInFile(opts, f, cft, targets, &records, vars, absPkg, &errs)
+		if err != nil {
+			return &rewriteResult{file: name, errs: []error{err}, records: records}
+		}
+		if newf != nil {
+			changed = true
+			f = newf
 		}
 	}
 	if !changed {
 		return &rewriteResult{file: name, errs: errs, records: records}
 	}
-	f = RemoveEmptyPackage(f)
-	f = RemoveEmptyUseRepoCalls(f)
-	ndata, err := buildifier.Buildify(opts, f)
+	ndata, err := cleanAndBuildify(opts, f)
 	if err != nil {
 		return &rewriteResult{file: name, errs: []error{fmt.Errorf("running buildifier: %v", err)}, records: records}
 	}
@@ -1297,6 +1274,58 @@
 	return &rewriteResult{file: name, errs: errs, modified: true, records: records}
 }
 
+// executeCommandsInFile executes the provided commandsForTarget in the provided build.File.
+func executeCommandsInFile(
+	opts *Options,
+	f *build.File,
+	cft commandsForTarget,
+	rules []*build.Rule,
+	records *[]*apipb.Output_Record,
+	vars map[string]*build.AssignExpr,
+	absPkg string,
+	errs *[]error,
+) (*build.File, error) {
+	changed := false
+	for _, cmd := range cft.commands {
+		cmdInfo := AllCommands[cmd.tokens[0]]
+		// Depending on whether a transformation is rule-specific or not, it should be applied to
+		// every rule that satisfies the filter or just once to the file.
+		cmdTargets := rules
+		if !cmdInfo.PerRule {
+			cmdTargets = []*build.Rule{nil}
+		}
+		for _, r := range cmdTargets {
+			record := &apipb.Output_Record{}
+			newf, err := cmdInfo.Fn(opts, CmdEnvironment{f, r, vars, absPkg, cmd.tokens[1:], record})
+			if len(record.Fields) != 0 {
+				*records = append(*records, record)
+			}
+			if err != nil {
+				cerr := commandError([]command{cmd}, cft.target, err)
+				if opts.KeepGoing {
+					*errs = append(*errs, cerr)
+				} else {
+					return nil, cerr
+				}
+			}
+			if newf != nil {
+				f = newf
+				changed = true
+			}
+		}
+	}
+	if changed {
+		return f, nil
+	}
+	return nil, nil
+}
+
+func cleanAndBuildify(opts *Options, f *build.File) ([]byte, error) {
+	f = RemoveEmptyPackage(f)
+	f = RemoveEmptyUseRepoCalls(f)
+	return buildifier.Buildify(opts, f)
+}
+
 // EditFile is a function that does any prework needed before editing a file.
 // e.g. "checking out for write" from a locking source control repo.
 var EditFile = func(fi os.FileInfo, name string) error {
@@ -1633,3 +1662,82 @@
 	}
 	return 0
 }
+
+// ExecuteCommandsOnInlineFile executes the given commands on the given file content.
+// Returns the new file content after applying the commands.
+func ExecuteCommandsOnInlineFile(fileContent []byte, commands []string) ([]byte, error) {
+	opts := Options{}
+	commandsByTargetName, filename, err := groupCommandsForInlineFile(commands, opts)
+	if err != nil {
+		return nil, err
+	}
+	f, err := build.Parse(*filename, fileContent)
+	if err != nil {
+		return nil, err
+	}
+	if f.Type == build.TypeDefault {
+		// Buildozer is unable to infer the file type, fall back to BUILD by default.
+		f.Type = build.TypeBuild
+	}
+	for _, cft := range commandsByTargetName {
+		rules, err := expandTargets(f, cft.target)
+		if err != nil {
+			return nil, err
+		}
+		newf, err := executeCommandsInFile(
+			&opts,
+			f,
+			cft,
+			rules,
+			// Output records are ignored in inline file execution.
+			nil,
+			// Global variables not supported in inline file execution.
+			nil,
+			f.Pkg,
+			// Errors-list is ignored since opts.keepGoing is always false.
+			nil,
+		)
+		if err != nil {
+			return nil, err
+		}
+		if newf != nil {
+			f = newf
+		}
+	}
+	outputFileContent, err := cleanAndBuildify(&opts, f)
+	if err != nil {
+		return nil, err
+	}
+	return outputFileContent, nil
+}
+
+// groupCommandsForInlineFile groups the given commands by file and returns the commands for a
+// single file. Returns an error if the commands modify multiple files or if commands are invalid.
+func groupCommandsForInlineFile(commands []string, opts Options) ([]commandsForTarget, *string, error) {
+	commandsByFile := make(map[string][]commandsForTarget)
+	commandReader := strings.NewReader(strings.Join(commands, "\n"))
+	err := appendCommandsFromReader(&opts, commandReader, commandsByFile, nil)
+	if err != nil {
+		return nil, nil, fmt.Errorf("error parsing commands %s", err)
+	}
+	if len(commandsByFile) != 1 {
+		return nil, nil, fmt.Errorf("invalid input commands, expected all commands to reference a single file")
+	}
+	for filepath, commandsForTargets := range commandsByFile {
+		for i := range commandsForTargets {
+			cft := &commandsForTargets[i]
+			splitTarget := strings.Split(cft.target, ":")
+			switch len(splitTarget) {
+			case 1: // No-op
+			case 2:
+				// Only keeps target (what is after the ":" character) since path is redundant.
+				cft.target = splitTarget[1]
+			default:
+				return nil, nil, fmt.Errorf("invalid target name %q", cft.target)
+			}
+		}
+		_, filename := path.Split(filepath)
+		return commandsForTargets, &filename, nil
+	}
+	panic("unreachable")
+}
diff --git a/edit/buildozer_test.go b/edit/buildozer_test.go
index 34689b8..d6c81ee 100644
--- a/edit/buildozer_test.go
+++ b/edit/buildozer_test.go
@@ -17,6 +17,7 @@
 package edit
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"reflect"
@@ -813,6 +814,170 @@
 	}
 }
 
+func TestExecuteCommandsOnInlineFile(t *testing.T) {
+	tests := []struct {
+		name        string
+		fileContent []byte
+		commands    []string
+		wantOutput  []byte
+	}{
+		{
+			name:        "creating_new_target_and_adding_deps",
+			fileContent: nil,
+			commands: []string{
+				"new java_library foo|//package/path/BUILD",
+				"add deps :bar|//package/path:foo",
+			},
+			wantOutput: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "foo",`,
+				`    deps = [":bar"],`,
+				`)`,
+				``}, "\n")),
+		},
+		{
+			name: "adding_deps_to_existing_targets",
+			fileContent: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "foo",`,
+				`)`,
+				``,
+				`java_library(`,
+				`    name = "fruits",`,
+				`    deps = ["//package/fruits:apples"],`,
+				`)`,
+				``}, "\n")),
+			commands: []string{
+				"add deps :bar|//package/path:foo",
+				"add deps //package/fruits:oranges|//package/path:fruits",
+			},
+			wantOutput: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "foo",`,
+				`    deps = [":bar"],`,
+				`)`,
+				``,
+				`java_library(`,
+				`    name = "fruits",`,
+				`    deps = [`,
+				`        "//package/fruits:apples",`,
+				`        "//package/fruits:oranges",`,
+				`    ],`,
+				`)`,
+				``}, "\n")),
+		},
+		{
+			name: "substituting_a_target",
+			fileContent: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "fruits",`,
+				`    deps = ["//package/fruits:apples"],`,
+				`)`,
+				``}, "\n")),
+			commands: []string{
+				"replace deps //package/fruits:apples //package/fruits:oranges|//whatever/package/path:fruits",
+			},
+			wantOutput: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "fruits",`,
+				`    deps = ["//package/fruits:oranges"],`,
+				`)`,
+				``}, "\n")),
+		},
+		{
+			name: "no_changes_does_not_return_any_diff",
+			fileContent: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "foo",`,
+				`    deps = [":bar"],`,
+				`)`,
+				``}, "\n")),
+			commands: []string{
+				"add deps :bar |//whatever/package/path:foo",
+			},
+			wantOutput: []byte(strings.Join([]string{
+				`java_library(`,
+				`    name = "foo",`,
+				`    deps = [":bar"],`,
+				`)`,
+				``}, "\n")),
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			output, err := ExecuteCommandsOnInlineFile(tc.fileContent, tc.commands)
+			if err != nil {
+				t.Fatalf("Error, got error %v", err)
+			}
+
+			if diff := cmp.Diff(tc.wantOutput, output); diff != "" {
+				t.Errorf("%s: (-want +got): %s", tc.name, diff)
+			}
+		})
+	}
+}
+
+func TestTestExecuteCommandsOnInlineFileFailed(t *testing.T) {
+	tests := []struct {
+		name        string
+		fileContent []byte
+		commands    []string
+		wantErr     error
+	}{
+		{
+			name: "target_does_not_exist",
+			commands: []string{
+				"add deps :foo|//package/path:bar",
+			},
+			wantErr: fmt.Errorf("rule 'bar' not found"),
+		},
+		{
+			name: "invalid_input",
+			commands: []string{
+				"completely invalid command",
+			},
+			wantErr: fmt.Errorf("rule 'completely invalid command' not found"),
+		},
+		{
+			name: "missing_implementation",
+			commands: []string{
+				"extrapolate packages :foo|//package/path:bar",
+			},
+			wantErr: fmt.Errorf("invalid input commands, expected all commands to reference a single file"),
+		},
+		{
+			name: "commands_for_multiple_files",
+			commands: []string{
+				"add deps :foo|//package/path:bar",
+				"add deps :foo|//package2/path:bar",
+			},
+			wantErr: fmt.Errorf("invalid input commands, expected all commands to reference a single file"),
+		},
+		{
+			name: "command_with_unexpected_target_semicolons",
+			commands: []string{
+				"add deps :foo|//package:path:bar",
+			},
+			wantErr: fmt.Errorf("invalid target name \"//package:path:bar\""),
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			output, gotErr := ExecuteCommandsOnInlineFile(tc.fileContent, tc.commands)
+
+			if output != nil {
+				t.Fatalf("Error, got response for invalid input %v, expected error", output)
+			}
+
+			if diff := cmp.Diff(tc.wantErr.Error(), gotErr.Error()); diff != "" {
+				t.Errorf("%s: (-want +got): %s", tc.name, diff)
+			}
+		})
+	}
+}
+
 func TestGetIgnoredPrefixes(t *testing.T) {
 	tmp, err := os.MkdirTemp("", "")
 	if err != nil {