build/toolbase: init

In an effort to better the developer/CI experience, I'm moving some of
our presubmit checks into a Go tool. This is a helper library that will
be used to interact with a Monogon workspace checkout from Go, both in
the new presubmit tool but also any other future tools that would like
to operate on source code.

Change-Id: Ie5f1b1d0153a1c853c241e167d2d3a469c636c94
Reviewed-on: https://review.monogon.dev/c/monogon/+/328
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/build/toolbase/BUILD.bazel b/build/toolbase/BUILD.bazel
new file mode 100644
index 0000000..6fd1489
--- /dev/null
+++ b/build/toolbase/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "doc.go",
+        "label.go",
+        "workspace.go",
+    ],
+    importpath = "source.monogon.dev/build/toolbase",
+    visibility = ["//visibility:public"],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["label_test.go"],
+    embed = [":go_default_library"],
+    deps = ["@com_github_google_go_cmp//cmp:go_default_library"],
+)
diff --git a/build/toolbase/doc.go b/build/toolbase/doc.go
new file mode 100644
index 0000000..8bf9e25
--- /dev/null
+++ b/build/toolbase/doc.go
@@ -0,0 +1,3 @@
+// toolbase implements common functionality for tools operating on a Monogon
+// workspace, notably CI and developer tools.
+package toolbase
diff --git a/build/toolbase/label.go b/build/toolbase/label.go
new file mode 100644
index 0000000..b6e8fda
--- /dev/null
+++ b/build/toolbase/label.go
@@ -0,0 +1,87 @@
+package toolbase
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// BazelLabel is a label, as defined by Bazel's documentation:
+//
+// https://docs.bazel.build/versions/main/skylark/lib/Label.html
+type BazelLabel struct {
+	WorkspaceName string
+	PackagePath   []string
+	Name          string
+}
+
+func (b BazelLabel) Package() string {
+	return strings.Join(b.PackagePath, "/")
+}
+
+func (b BazelLabel) String() string {
+	return fmt.Sprintf("@%s//%s:%s", b.WorkspaceName, b.Package(), b.Name)
+}
+
+var (
+	// reLabel splits a Bazel label into a workspace name (if set) and a
+	// workspace root relative package/name.
+	reLabel = regexp.MustCompile(`^(@[^:/]+)?//(.+)$`)
+	// rePathPart matches valid label path parts.
+	rePathPart = regexp.MustCompile(`^[^:/]+$`)
+)
+
+// ParseBazelLabel converts parses a string representation of a Bazel Label. If
+// the given representation is invalid or for some other reason unparseable,
+// nil is returned.
+func ParseBazelLabel(s string) *BazelLabel {
+	res := BazelLabel{
+		WorkspaceName: "dev_source_monogon",
+	}
+
+	// Split label into workspace name (if set) and a workspace root relative
+	// package/name.
+	m := reLabel.FindStringSubmatch(s)
+	if m == nil {
+		return nil
+	}
+	packageRel := m[2]
+	if m[1] != "" {
+		res.WorkspaceName = m[1][1:]
+	}
+
+	// Split path by ':', which is the target name delimiter. If it appears
+	// exactly once, interpret everything to its right as the target name.
+	targetSplit := strings.Split(packageRel, ":")
+	switch len(targetSplit) {
+	case 1:
+	case 2:
+		packageRel = targetSplit[0]
+		res.Name = targetSplit[1]
+		if !rePathPart.MatchString(res.Name) {
+			return nil
+		}
+	default:
+		return nil
+	}
+
+	// Split the package path by /, and if the name was not explicitly given,
+	// use the last element of the package path.
+	if packageRel == "" {
+		res.PackagePath = nil
+	} else {
+		res.PackagePath = strings.Split(packageRel, "/")
+	}
+	if res.Name == "" {
+		res.Name = res.PackagePath[len(res.PackagePath)-1]
+	}
+
+	// Ensure all parts of the package path are valid.
+	for _, p := range res.PackagePath {
+		if !rePathPart.MatchString(p) {
+			return nil
+		}
+	}
+
+	return &res
+}
diff --git a/build/toolbase/label_test.go b/build/toolbase/label_test.go
new file mode 100644
index 0000000..190c834
--- /dev/null
+++ b/build/toolbase/label_test.go
@@ -0,0 +1,59 @@
+package toolbase
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestBazelLabelParse(t *testing.T) {
+	for i, te := range []struct {
+		p string
+		t *BazelLabel
+	}{
+		{"//foo/bar", &BazelLabel{"dev_source_monogon", []string{"foo", "bar"}, "bar"}},
+		{"//foo/bar:baz", &BazelLabel{"dev_source_monogon", []string{"foo", "bar"}, "baz"}},
+		{"//:foo", &BazelLabel{"dev_source_monogon", nil, "foo"}},
+
+		{"@test//foo/bar", &BazelLabel{"test", []string{"foo", "bar"}, "bar"}},
+		{"@test//foo/bar:baz", &BazelLabel{"test", []string{"foo", "bar"}, "baz"}},
+		{"@test//:foo", &BazelLabel{"test", nil, "foo"}},
+
+		{"", nil},
+		{"//", nil},
+		{"//foo:bar/foo", nil},
+		{"//foo//bar/foo", nil},
+		{"/foo/bar/foo", nil},
+		{"foo/bar/foo", nil},
+		{"@//foo/bar/foo", nil},
+		{"@foo/bar//foo/bar/foo", nil},
+		{"@foo:bar//foo/bar/foo", nil},
+		{"foo//foo/bar/foo", nil},
+	} {
+		want := te.t
+		got := ParseBazelLabel(te.p)
+		if diff := cmp.Diff(want, got); diff != "" {
+			t.Errorf("case %d (%q): %s", i, te.p, diff)
+		}
+	}
+}
+
+func TestBazelLabelString(t *testing.T) {
+	for i, te := range []struct {
+		in   string
+		want string
+	}{
+		{"//foo/bar", "@dev_source_monogon//foo/bar:bar"},
+		{"//foo:bar", "@dev_source_monogon//foo:bar"},
+		{"@com_github_example//:run", "@com_github_example//:run"},
+	} {
+		l := ParseBazelLabel(te.in)
+		if l == nil {
+			t.Errorf("case %d: wanted %q, got nil", i, te.want)
+			continue
+		}
+		if want, got := te.want, l.String(); want != got {
+			t.Errorf("case %d: wanted %q, got %q", i, want, got)
+		}
+	}
+}
diff --git a/build/toolbase/workspace.go b/build/toolbase/workspace.go
new file mode 100644
index 0000000..0994ac5
--- /dev/null
+++ b/build/toolbase/workspace.go
@@ -0,0 +1,38 @@
+package toolbase
+
+import (
+	"fmt"
+	"os"
+	"path"
+)
+
+// isWorkspace returns whether a given string is a valid path pointing to a
+// Bazel workspace directory.
+func isWorkspace(dir string) bool {
+	w := path.Join(dir, "WORKSPACE")
+	if _, err := os.Stat(w); err == nil {
+		return true
+	}
+	return false
+}
+
+// WorkspaceDirectory returns the workspace directory from which a given
+// command line tool is running. This handles the following cases:
+//
+// 1. The command line tool was invoked via `bazel run`.
+// 2. The command line tool was started directly in a workspace directory (but
+//    not a subdirectory).
+//
+// If the workspace directory path cannot be inferred based on the above
+// assumptions, an error is returned.
+func WorkspaceDirectory() (string, error) {
+	if p := os.Getenv("BUILD_WORKSPACE_DIRECTORY"); p != "" && isWorkspace(p) {
+		return p, nil
+	}
+
+	if p, err := os.Getwd(); err != nil && isWorkspace(p) {
+		return p, nil
+	}
+
+	return "", fmt.Errorf("not invoked from `bazel run` and not running in workspace directory")
+}