From 58c416f1bdb118f1bf17c7a24df46172358196c1 Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Tue, 6 Feb 2024 12:37:41 -0700
Subject: [PATCH] Bazelshim

---
 .envrc                                       |   2 +
 bin/bazel                                    |   2 +
 bin/bazelisk                                 |   2 +
 projects/bazelshim/src/bazelshim/__main__.py | 163 +++++++++++++++++--
 projects/bazelshim/test/test_normalizer.py   |  74 +++++++++
 projects/bazelshim/test/test_parser.py       |  52 ++++++
 6 files changed, 277 insertions(+), 18 deletions(-)
 create mode 100644 .envrc
 create mode 100755 bin/bazel
 create mode 100755 bin/bazelisk
 mode change 100644 => 100755 projects/bazelshim/src/bazelshim/__main__.py
 create mode 100644 projects/bazelshim/test/test_normalizer.py
 create mode 100644 projects/bazelshim/test/test_parser.py

diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..c589014
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,2 @@
+export SOURCE=$(dirname $(realpath $0))
+export PATH="${SOURCE}/bin:$PATH"
diff --git a/bin/bazel b/bin/bazel
new file mode 100755
index 0000000..40517be
--- /dev/null
+++ b/bin/bazel
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec "${SOURCE}/projects/bazelshim/src/bazelshim/__main__.py" --bazelshim_exclude="$(realpath "${SOURCE}")/bin" "$@"
diff --git a/bin/bazelisk b/bin/bazelisk
new file mode 100755
index 0000000..40517be
--- /dev/null
+++ b/bin/bazelisk
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec "${SOURCE}/projects/bazelshim/src/bazelshim/__main__.py" --bazelshim_exclude="$(realpath "${SOURCE}")/bin" "$@"
diff --git a/projects/bazelshim/src/bazelshim/__main__.py b/projects/bazelshim/src/bazelshim/__main__.py
old mode 100644
new mode 100755
index 9cc9f21..b953403
--- a/projects/bazelshim/src/bazelshim/__main__.py
+++ b/projects/bazelshim/src/bazelshim/__main__.py
@@ -2,15 +2,48 @@
 
 # A Bazel wrapper
 #
-# This script exists to allow for the setting of environment viariables and other context flags to Bazel on behalf of
-# the user. Consequently it has some magical (partial) knowledge of Bazel's CLI options since it's really a CLI shim.
+# This script exists to allow for the setting of environment viariables and other context flags to
+# Bazel on behalf of the user. Consequently it has some magical (partial) knowledge of Bazel's CLI
+# options since it's really a CLI shim.
 
-import sys
-from typing import List, Optional
-from shlex import split as shlex
 from dataclasses import dataclass
+from shlex import quote, split as shlex
+import sys
+import os
+from pathlib import Path
+from typing import List, Optional
+from itertools import chain
+
+
+VERBS = [
+    "aquery",
+    "build",
+    "clean",
+    "coverage",
+    "cquery",
+    "dump",
+    "fetch",
+    "help",
+    "info",
+    "mod",
+    "query",
+    "run",
+    "sync",
+    "test",
+]
+
+
+def path():
+    for it in os.getenv("PATH").split(":"):
+        yield Path(it)
+
+
+def which(cmd):
+    for it in path():
+        f: Path = (it / cmd).absolute()
+        if f.exists() and f.stat().st_mode & 0x700:
+            yield f
 
-VERBS = ["sync", "build", "aquery", "query", "cquery", "run", "test", "coverage", "dump", "fetch", "help", "info", "mod",]
 
 def normalize_opts(args: List[str]) -> List[str]:
     acc = []
@@ -24,7 +57,7 @@ def normalize_opts(args: List[str]) -> List[str]:
             acc.extend(args)
             break
 
-        elif args[0].contains("="):
+        elif "=" in args[0]:
             # If it's a k/v form pass it through
             acc.append(args.pop(0))
 
@@ -32,7 +65,11 @@ def normalize_opts(args: List[str]) -> List[str]:
             # Convert --no<foo> args to --<foo>=no
             acc.append("--" + args.pop(0).lstrip("--no") + "=false")
 
-        elif args[0].startswith("--") and not args[1].startswith("--") and args[1] not in VERBS:
+        elif (
+            args[0].startswith("--")
+            and not args[1].startswith("--")
+            and args[1] not in VERBS
+        ):
             # If the next thing isn't an opt, assume it's a '--a b' form
             acc.append(args[0] + "=" + args[1])
             args.pop(0)
@@ -42,27 +79,117 @@ def normalize_opts(args: List[str]) -> List[str]:
             # Assume it's a boolean true flag
             acc.append(args.pop(0) + "=true")
 
-        elif args[0] in VERBS:
+        else:
             acc.append(args.pop(0))
 
-        else:
-            raise ValueError(repr(args))
+    else:
+        if args:
+            acc.extend(args)
 
     return acc
 
-assert normalize_opts(shlex("bazel clean")) == ["bazel", "clean"]
-assert normalize_opts(shlex("bazel --client_debug clean")) == ["bazel", "--client_debug=true", "clean"]
-assert normalize_opts(shlex("bazel build //foo:bar //baz:*")) == ["bazel", "build", "//foo:bar", "//baz:*"]
-assert normalize_opts(shlex("bazel test //foo:bar //baz:* -- -vvv")) == ["bazel", "test", "//foo:bar", "//baz:*", "--", "-vvv"]
-assert normalize_opts(shlex("bazel run //foo:bar -- --foo=bar --baz=qux")) == ["bazel", "run", "//foo:bar", "--", "--foo=bar", "--baz=qux"]
 
 @dataclass
 class BazelCli:
+    binary: str
     startup_opts: List[str]
     command: Optional[str]
     command_opts: List[str]
     subprocess_opts: List[str]
 
     @classmethod
-    def parse_cli(cls, args: List[str]) -> BazelCLI:
-        pass
+    def parse_cli(cls, args: List[str]) -> "BazelCli":
+        args = normalize_opts(args)
+        binary = args.pop(0)
+
+        startup_opts = []
+        while args and args[0].startswith("--"):
+            startup_opts.append(args.pop(0))
+
+        command = None
+        if args and args[0] in VERBS:
+            command = args.pop(0)
+
+        command_opts = []
+        while args and args[0] != "--":
+            command_opts.append(args.pop(0))
+
+        subprocess_opts = []
+        if args:
+            if args[0] == "--":
+                args.pop(0)
+            subprocess_opts.extend(args)
+
+        return cls(
+            binary=binary,
+            startup_opts=startup_opts,
+            command=command,
+            command_opts=command_opts,
+            subprocess_opts=subprocess_opts,
+        )
+
+    def render_cli(self):
+        acc = [
+            self.binary,
+            *self.startup_opts,
+        ]
+        if self.command:
+            acc.append(self.command)
+            acc.extend(self.command_opts)
+
+            if self.command == "test":
+                acc.extend(["--test_arg=" + it for it in self.subprocess_opts])
+            elif self.command == "run":
+                acc.append("--")
+                acc.extend(self.subprocess_opts)
+            else:
+                print(
+                    f"Warning: {self.command} does not support -- args!",
+                    file=sys.stderr,
+                )
+
+        return acc
+
+    def executable(self, exclude: List[Path]):
+        """Try to resolve as via which() an executable to delegate to."""
+
+        for p in chain(which("bazelisk"), which("bazel")):
+            if p.parent not in exclude:
+                return str(p)
+
+    def render_text(self, next):
+        lines = []
+        lines.append("  " + next)
+        for arg in self.startup_opts:
+            lines.append("    " + arg)
+        if self.command:
+            lines.append("    " + self.command)
+            for arg in self.command_opts:
+                lines.append("      " + arg)
+        if self.subprocess_opts:
+            lines.append("    --")
+            for arg in self.subprocess_opts:
+                lines.append("      " + arg)
+        return "\\\n".join(lines)
+
+
+def middleware(cli):
+    return cli
+
+
+if __name__ == "__main__":
+    exclude = []
+    while len(sys.argv) > 1 and sys.argv[1].startswith("--bazelshim_exclude"):
+        exclude.append(Path(sys.argv.pop(1).split("=")[1]).absolute())
+
+    us = Path(sys.argv[0]).absolute()
+    exclude.append(us.parent)
+
+    cli = BazelCli.parse_cli(["bazel"] + sys.argv[1:])
+    cli = middleware(cli)
+    next = cli.executable(exclude=exclude)
+    print(
+        "Info: Executing\n" + cli.render_text(next),
+        file=sys.stderr,
+    )
+    os.execv(next, cli.render_cli())
diff --git a/projects/bazelshim/test/test_normalizer.py b/projects/bazelshim/test/test_normalizer.py
new file mode 100644
index 0000000..a2cf3ca
--- /dev/null
+++ b/projects/bazelshim/test/test_normalizer.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+
+from shlex import split as shlex
+
+from bazelshim.__main__ import normalize_opts
+
+import pytest
+
+
+@pytest.mark.parametrize(
+    "a, b",
+    [
+        (
+            "bazel clean",
+            [
+                "bazel",
+                "clean",
+            ],
+        ),
+        (
+            "bazel --client_debug clean",
+            [
+                "bazel",
+                "--client_debug=true",
+                "clean",
+            ],
+        ),
+        (
+            "bazel build //foo:bar //baz:*",
+            [
+                "bazel",
+                "build",
+                "//foo:bar",
+                "//baz:*",
+            ],
+        ),
+        (
+            "bazel test //foo:bar //baz:* -- -vvv",
+            [
+                "bazel",
+                "test",
+                "//foo:bar",
+                "//baz:*",
+                "--",
+                "-vvv",
+            ],
+        ),
+        (
+            "bazel test --shell_executable /bin/bish //foo:bar //baz:* -- -vvv",
+            [
+                "bazel",
+                "test",
+                "--shell_executable=/bin/bish",
+                "//foo:bar",
+                "//baz:*",
+                "--",
+                "-vvv",
+            ],
+        ),
+        (
+            "bazel run //foo:bar -- --foo=bar --baz=qux",
+            [
+                "bazel",
+                "run",
+                "//foo:bar",
+                "--",
+                "--foo=bar",
+                "--baz=qux",
+            ],
+        ),
+    ],
+)
+def test_normalize_opts(a, b):
+    assert normalize_opts(shlex(a)) == b
diff --git a/projects/bazelshim/test/test_parser.py b/projects/bazelshim/test/test_parser.py
new file mode 100644
index 0000000..c2cc3d0
--- /dev/null
+++ b/projects/bazelshim/test/test_parser.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+
+from shlex import split as shlex
+
+from bazelshim.__main__ import BazelCli
+
+import pytest
+
+
+@pytest.mark.parametrize(
+    "a, b",
+    [
+        (
+            "bazel clean",
+            BazelCli("bazel", [], "clean", [], []),
+        ),
+        (
+            "bazel --client_debug clean",
+            BazelCli("bazel", ["--client_debug=true"], "clean", [], []),
+        ),
+        (
+            "bazel build //foo:bar //baz:*",
+            BazelCli("bazel", [], "build", ["//foo:bar", "//baz:*"], []),
+        ),
+        (
+            "bazel test //foo:bar //baz:* -- -vvv",
+            BazelCli("bazel", [], "test", ["//foo:bar", "//baz:*"], ["-vvv"]),
+        ),
+        (
+            "bazel test --shell_executable /bin/bish //foo:bar //baz:* -- -vvv",
+            BazelCli(
+                "bazel",
+                [],
+                "test",
+                ["--shell_executable=/bin/bish", "//foo:bar", "//baz:*"],
+                ["-vvv"],
+            ),
+        ),
+        (
+            "bazel run //foo:bar -- --foo=bar --baz=qux",
+            BazelCli(
+                "bazel",
+                [],
+                "run",
+                ["//foo:bar"],
+                ["--foo=bar", "--baz=qux"],
+            ),
+        ),
+    ],
+)
+def test_normalize_opts(a, b):
+    assert BazelCli.parse_cli(shlex(a)) == b