Use launcher wrapper to initialize runfiles (#38)

This avoid the need to copy&paste the verbose initialization snippet
into every script.

This should resolve the issues raised in #24.

---------

Co-authored-by: Fabian Meumertzheim <fabian@meumertzhe.im>
diff --git a/shell/private/sh_executable.bzl b/shell/private/sh_executable.bzl
index 6644046..a04dff8 100644
--- a/shell/private/sh_executable.bzl
+++ b/shell/private/sh_executable.bzl
@@ -18,33 +18,68 @@
 
 _SH_TOOLCHAIN_TYPE = Label("//shell:toolchain_type")
 
+def _to_rlocation_path(ctx, file):
+    if file.short_path.startswith("../"):
+        return file.short_path[3:]
+    else:
+        return ctx.workspace_name + "/" + file.short_path
+
 def _sh_executable_impl(ctx):
     if len(ctx.files.srcs) != 1:
         fail("you must specify exactly one file in 'srcs'", attr = "srcs")
-
-    symlink = ctx.actions.declare_file(ctx.label.name)
     src = ctx.files.srcs[0]
 
-    ctx.actions.symlink(
-        output = symlink,
-        target_file = src,
-        is_executable = True,
-        progress_message = "Symlinking %{label}",
-    )
+    direct_files = [src]
+    transitive_files = []
+    runfiles = ctx.runfiles(collect_default = True)
 
-    direct_files = [src, symlink]
+    entrypoint = ctx.actions.declare_file(ctx.label.name)
+    if ctx.attr.use_bash_launcher:
+        ctx.actions.write(
+            entrypoint,
+            content = """#!{shell}
+
+# --- begin runfiles.bash initialization v3 ---
+set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+# shellcheck disable=SC1090
+source "${{RUNFILES_DIR:-/dev/null}}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${{RUNFILES_MANIFEST_FILE:-/dev/null}}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  {{ echo>&2 "ERROR: cannot find $f"; exit 1; }}; f=; set -e
+# --- end runfiles.bash initialization v3 ---
+
+runfiles_export_envvars
+
+exec "$(rlocation "{src}")" "$@"
+""".format(
+                shell = ctx.toolchains[_SH_TOOLCHAIN_TYPE].path,
+                src = _to_rlocation_path(ctx, src),
+            ),
+            is_executable = True,
+        )
+        runfiles = runfiles.merge(ctx.attr._runfiles_dep[DefaultInfo].default_runfiles)
+    else:
+        ctx.actions.symlink(
+            output = entrypoint,
+            target_file = src,
+            is_executable = True,
+        )
+
+    direct_files.append(entrypoint)
 
     # TODO: Consider extracting this logic into a function provided by
     # sh_toolchain to allow users to inject launcher creation logic for
     # non-Windows platforms.
     if ctx.target_platform_has_constraint(ctx.attr._windows_constraint[platform_common.ConstraintValueInfo]):
-        main_executable = _launcher_for_windows(ctx, symlink, src)
+        main_executable = _launcher_for_windows(ctx, entrypoint, src)
         direct_files.append(main_executable)
     else:
-        main_executable = symlink
+        main_executable = entrypoint
 
-    files = depset(direct = direct_files)
-    runfiles = ctx.runfiles(transitive_files = files, collect_default = True)
+    files = depset(direct = direct_files, transitive = transitive_files)
+    runfiles = runfiles.merge(ctx.runfiles(transitive_files = files))
     default_info = DefaultInfo(
         executable = main_executable,
         files = files,
@@ -54,7 +89,7 @@
     instrumented_files_info = coverage_common.instrumented_files_info(
         ctx,
         source_attributes = ["srcs"],
-        dependency_attributes = ["deps", "data"],
+        dependency_attributes = ["deps", "_runfiles_dep", "data"],
     )
 
     run_environment_info = RunEnvironmentInfo(
@@ -188,8 +223,14 @@
 </p>
 """,
             ),
+            "_runfiles_dep": attr.label(
+                default = Label("//shell/runfiles"),
+            ),
             "env": attr.string_dict(),
             "env_inherit": attr.string_list(),
+            "use_bash_launcher": attr.bool(
+                doc = "Use a bash launcher initializing the runfiles library",
+            ),
             "_windows_constraint": attr.label(
                 default = "@platforms//os:windows",
             ),
diff --git a/shell/runfiles/runfiles.bash b/shell/runfiles/runfiles.bash
index 169ee62..3dfe3e2 100644
--- a/shell/runfiles/runfiles.bash
+++ b/shell/runfiles/runfiles.bash
@@ -84,6 +84,8 @@
 #
 #       cat "$(rlocation my_workspace/path/to/my/data.txt)"
 #
+# You can skip steps 1 and 2 when setting "use_bash_launcher" attribute in sh_binary or sh_test.
+#
 
 if [[ ! -d "${RUNFILES_DIR:-/dev/null}" && ! -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
   if [[ -f "$0.runfiles_manifest" ]]; then
diff --git a/tests/bcr/MODULE.bazel b/tests/bcr/MODULE.bazel
index cf3cfc8..8593251 100644
--- a/tests/bcr/MODULE.bazel
+++ b/tests/bcr/MODULE.bazel
@@ -5,3 +5,5 @@
     module_name = "rules_shell",
     path = "../..",
 )
+
+bazel_dep(name = "bazel_features", version = "1.34.0")
diff --git a/tests/bcr/bash_launcher/BUILD b/tests/bcr/bash_launcher/BUILD
new file mode 100644
index 0000000..1fceb50
--- /dev/null
+++ b/tests/bcr/bash_launcher/BUILD
@@ -0,0 +1,26 @@
+# Use private rule implementations directly if all required features are available to provide test
+# coverage for Bazel 8.
+load("@bazel_features//:features.bzl", "bazel_features")
+load("@rules_shell//shell/private:sh_binary.bzl", "sh_binary")  # buildifier: disable=bzl-visibility
+load("@rules_shell//shell/private:sh_library.bzl", "sh_library")  # buildifier: disable=bzl-visibility
+load("@rules_shell//shell/private:sh_test.bzl", "sh_test")  # buildifier: disable=bzl-visibility
+
+sh_library(
+    name = "lib",
+    srcs = ["lib.sh"],
+    data = ["greeting.txt"],
+)
+
+bazel_features.rules._has_launcher_maker_toolchain and sh_binary(
+    name = "bin",
+    srcs = ["bin.sh"],
+    use_bash_launcher = True,
+    deps = [":lib"],
+)  # buildifier: disable=no-effect
+
+bazel_features.rules._has_launcher_maker_toolchain and sh_test(
+    name = "test",
+    srcs = ["test.sh"],
+    data = [":bin"],
+    use_bash_launcher = True,
+)  # buildifier: disable=no-effect
diff --git a/tests/bcr/bash_launcher/bin.sh b/tests/bcr/bash_launcher/bin.sh
new file mode 100755
index 0000000..7852df6
--- /dev/null
+++ b/tests/bcr/bash_launcher/bin.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# 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.
+
+source "$(rlocation "rules_shell_tests/bash_launcher/lib.sh")"
+
+get_greeting
diff --git a/tests/bcr/bash_launcher/greeting.txt b/tests/bcr/bash_launcher/greeting.txt
new file mode 100644
index 0000000..d0cf519
--- /dev/null
+++ b/tests/bcr/bash_launcher/greeting.txt
@@ -0,0 +1 @@
+hello from rules_shell
diff --git a/tests/bcr/bash_launcher/lib.sh b/tests/bcr/bash_launcher/lib.sh
new file mode 100755
index 0000000..649c790
--- /dev/null
+++ b/tests/bcr/bash_launcher/lib.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# 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.
+
+function get_greeting() {
+    greeting_path=$(rlocation "rules_shell_tests/bash_launcher/greeting.txt")
+    cat "${greeting_path}"
+}
diff --git a/tests/bcr/bash_launcher/test.sh b/tests/bcr/bash_launcher/test.sh
new file mode 100755
index 0000000..a4cc6ff
--- /dev/null
+++ b/tests/bcr/bash_launcher/test.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# 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.
+
+bin_path="$(rlocation "rules_shell_tests/bash_launcher/bin.sh")"
+if [[ ! -x "${bin_path}" ]]; then
+  echo "Expected '${bin_path}' to be an executable"
+  exit 1
+fi
+
+greeting=$("${bin_path}")
+if [[ "${greeting}" != "hello from rules_shell" ]]; then
+  echo "Expected 'hello from rules_shell', got '${greeting}'"
+  exit 1
+fi