diff --git a/ruby/private/binary.bzl b/ruby/private/binary.bzl index 0eb23015..d0bf8618 100644 --- a/ruby/private/binary.bzl +++ b/ruby/private/binary.bzl @@ -65,13 +65,17 @@ Supports `$(location)` expansion for targets from `srcs`, `data` and `deps`. allow_single_file = True, default = "@rules_ruby//ruby/private:coverage.rb", ), + "_runfiles_helper": attr.label( + allow_single_file = True, + default = "@rules_ruby//ruby/runfiles:lib/bazel/runfiles.rb", + ), "coverage_filters": attr.string_list( doc = "Additional coverage filters to add to SimpleCov. Only applied during 'bazel coverage'.", ), } # buildifier: disable=function-docstring -def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {}, java_bin = "", jars_home_strip_suffix = "", coverage_helper = "", is_jruby = False): +def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {}, java_bin = "", jars_home_strip_suffix = "", coverage_helper = "", runfiles_helper = "", is_jruby = False): toolchain = ctx.toolchains["@rules_ruby//ruby:toolchain_type"] if ctx.attr.ruby != None: toolchain = ctx.attr.ruby[platform_common.ToolchainInfo] @@ -129,6 +133,7 @@ def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {}, "{rlocation_function}": rlocation_function, "{locate_binary_in_runfiles}": locate_binary_in_runfiles, "{coverage_helper}": coverage_helper, + "{runfiles_helper}": runfiles_helper, "{is_jruby}": "true" if is_jruby else "", }, ) @@ -153,6 +158,7 @@ def rb_binary_impl(ctx): ruby_toolchain = ctx.attr.ruby[platform_common.ToolchainInfo] tools = list(ruby_toolchain.files) tools.append(ctx.file._coverage_helper) + tools.append(ctx.file._runfiles_helper) if ruby_toolchain.version.startswith("jruby"): java_toolchain = ctx.toolchains["@bazel_tools//tools/jdk:runtime_toolchain_type"] @@ -220,6 +226,7 @@ def rb_binary_impl(ctx): java_bin = java_bin, jars_home_strip_suffix = jars_home_strip_suffix, coverage_helper = _to_rlocation_path(ctx, ctx.file._coverage_helper), + runfiles_helper = _to_rlocation_path(ctx, ctx.file._runfiles_helper), is_jruby = ruby_toolchain.version.startswith("jruby"), ) diff --git a/ruby/private/binary/binary.cmd.tpl b/ruby/private/binary/binary.cmd.tpl index 15e77207..8cd22392 100644 --- a/ruby/private/binary/binary.cmd.tpl +++ b/ruby/private/binary/binary.cmd.tpl @@ -8,6 +8,10 @@ set RUNFILES_MANIFEST_ONLY=1 call :rlocation {ruby} ruby for %%a in ("!ruby!\..") do set PATH=%%~fa;%PATH% +:: Set RUBYLIB to include the runfiles library. +call :rlocation {runfiles_helper} _runfiles_helper_path +for %%a in ("!_runfiles_helper_path!\..\..") do set "RUBYLIB=%%~fa;!RUBYLIB!" + :: Find location of JAVA_HOME in runfiles. if "{java_bin}" neq "" ( call :rlocation {java_bin} java_bin diff --git a/ruby/private/binary/binary.sh.tpl b/ruby/private/binary/binary.sh.tpl index ec331623..c14bdd24 100644 --- a/ruby/private/binary/binary.sh.tpl +++ b/ruby/private/binary/binary.sh.tpl @@ -36,6 +36,9 @@ realpath() ( export RUNFILES_DIR="$(realpath "${RUNFILES_DIR:-$0.runfiles}")" +# Set RUBYLIB to include the runfiles library. +export RUBYLIB="$(dirname "$(dirname "$(rlocation "{runfiles_helper}")")")${RUBYLIB:+:${RUBYLIB}}" + # Find location of Ruby in runfiles. export PATH=$(dirname $(rlocation {ruby})):$PATH diff --git a/ruby/runfiles/BUILD b/ruby/runfiles/BUILD new file mode 100644 index 00000000..4954f1e4 --- /dev/null +++ b/ruby/runfiles/BUILD @@ -0,0 +1,16 @@ +load("//ruby:defs.bzl", "rb_library", "rb_test") + +exports_files(["lib/bazel/runfiles.rb"]) + +rb_library( + name = "runfiles", + srcs = ["lib/bazel/runfiles.rb"], + visibility = ["//visibility:public"], +) + +rb_test( + name = "runfiles_test", + size = "small", + srcs = ["runfiles_test.rb"], + tags = ["manual"], +) diff --git a/ruby/runfiles/lib/bazel/runfiles.rb b/ruby/runfiles/lib/bazel/runfiles.rb new file mode 100644 index 00000000..b8c275be --- /dev/null +++ b/ruby/runfiles/lib/bazel/runfiles.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'pathname' + +module Bazel + # Resolves runtime paths to data dependencies using either a + # manifest file or a runfiles directory. + class Runfiles + def self.create(env = ENV) + manifest_file = env['RUNFILES_MANIFEST_FILE'] + runfiles_dir = env['RUNFILES_DIR'] + + return new(ManifestBased.new(manifest_file)) if manifest_file && !manifest_file.empty? + return new(DirectoryBased.new(runfiles_dir)) if runfiles_dir && !runfiles_dir.empty? + + create_from_program_name($PROGRAM_NAME) + end + + def self.create_from_program_name(program_name) + if File.exist?("#{program_name}.runfiles_manifest") + new(ManifestBased.new("#{program_name}.runfiles_manifest")) + elsif File.exist?("#{program_name}.runfiles") + new(DirectoryBased.new("#{program_name}.runfiles")) + else + new(DirectoryBased.new('')) + end + end + + def initialize(strategy) + @strategy = strategy + end + + def rlocation(path) + raise ArgumentError, 'path must not be empty' if path.to_s.empty? + + invalid_path = %r{\A\.\.[/\\]|[/\\]\.\.[/\\]|\A\.[/\\]|[/\\]\.[/\\]|[/\\]\.\z|[/\\][/\\]} + raise ArgumentError, "path is not valid: #{path.inspect}" if path.match?(invalid_path) + + return path if Pathname.new(path).absolute? + + @strategy.rlocation(path) + end + + # Resolves paths by looking them up in a runfiles MANIFEST file. + class ManifestBased + def initialize(manifest_path) + @entries = parse_manifest(manifest_path) + end + + def rlocation(path) + return @entries[path] if @entries.key?(path) + + prefix = File.dirname(path) + while prefix != '.' && prefix != '/' + base = @entries[prefix] + return "#{base}#{path[prefix.length..]}" if base && !base.empty? + + prefix = File.dirname(prefix) + end + + nil + end + + private + + def parse_manifest(path) + entries = {} + return entries unless File.exist?(path) + + File.foreach(path) do |line| + line.chomp! + next if line.empty? + + key, value = parse_entry(line) + entries[key] = value + end + + entries + end + + def parse_entry(line) + escaped = line.delete_prefix!(' ') + key, _, value = line.partition(' ') + return [key, value] unless escaped + + [unescape(key), unescape(value)] + end + + def unescape(str) + str.gsub(/\\[snb]/, '\s' => ' ', '\n' => "\n", '\b' => '\\') + end + end + + # Resolves paths by joining them onto a runfiles directory root. + class DirectoryBased + def initialize(runfiles_dir) + @runfiles_dir = runfiles_dir + end + + def rlocation(path) + return nil if @runfiles_dir.empty? + + File.join(@runfiles_dir, path) + end + end + end +end diff --git a/ruby/runfiles/runfiles_test.rb b/ruby/runfiles/runfiles_test.rb new file mode 100644 index 00000000..d5e21633 --- /dev/null +++ b/ruby/runfiles/runfiles_test.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'tmpdir' +require 'bazel/runfiles' + +class ManifestBasedTest < Minitest::Test + def setup + @dir = Dir.mktmpdir + @manifest = File.join(@dir, 'MANIFEST') + end + + def teardown + FileUtils.rm_rf(@dir) + end + + def strategy(contents) + File.write(@manifest, contents) + Bazel::Runfiles::ManifestBased.new(@manifest) + end + + def test_exact_match + r = strategy("_main/pkg/file.txt /abs/pkg/file.txt\n") + assert_equal '/abs/pkg/file.txt', r.rlocation('_main/pkg/file.txt') + end + + def test_prefix_walk + r = strategy("_main/pkg /abs/pkg\n") + assert_equal '/abs/pkg/sub/file.txt', r.rlocation('_main/pkg/sub/file.txt') + end + + def test_missing_path_returns_nil + r = strategy("_main/pkg/file.txt /abs/pkg/file.txt\n") + assert_nil r.rlocation('_main/pkg/other.txt') + end + + def test_empty_file_convention + r = strategy("_main/empty.txt\n") + assert_equal '', r.rlocation('_main/empty.txt') + end + + def test_escaped_path_with_spaces + r = strategy(" _main/dir\\swith\\sspaces/file.txt /abs/dir\\swith\\sspaces/file.txt\n") + assert_equal '/abs/dir with spaces/file.txt', r.rlocation('_main/dir with spaces/file.txt') + end + + def test_escaped_path_with_backslash_and_newline + r = strategy(" _main/a\\bs\\nb /abs/a\\bs\\nb\n") + assert_equal "/abs/a\\s\nb", r.rlocation("_main/a\\s\nb") + end + + def test_escaped_empty_file + r = strategy(" _main/weird\\spath\n") + assert_equal '', r.rlocation('_main/weird path') + end + + def test_prefix_with_empty_value_not_matched_as_directory + r = strategy("_main/empty.txt \n_main/empty.txt/child.rb /abs/child.rb\n") + assert_equal '/abs/child.rb', r.rlocation('_main/empty.txt/child.rb') + end +end + +class DirectoryBasedTest < Minitest::Test + def setup + @dir = Dir.mktmpdir + end + + def teardown + FileUtils.rm_rf(@dir) + end + + def test_rlocation_joins_dir_and_path + r = Bazel::Runfiles::DirectoryBased.new(@dir) + assert_equal File.join(@dir, '_main/pkg/file.txt'), r.rlocation('_main/pkg/file.txt') + end + + def test_rlocation_returns_nil_when_no_dir + r = Bazel::Runfiles::DirectoryBased.new('') + assert_nil r.rlocation('_main/pkg/file.txt') + end +end + +class RunfilesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir + @manifest = File.join(@dir, 'MANIFEST') + File.write(@manifest, "_main/a.txt /abs/a.txt\n") + end + + def teardown + FileUtils.rm_rf(@dir) + end + + def directory_runfiles(dir = @dir) + Bazel::Runfiles.new(Bazel::Runfiles::DirectoryBased.new(dir)) + end + + def test_empty_path_raises + assert_raises(ArgumentError) { directory_runfiles.rlocation('') } + end + + def test_nil_path_raises + assert_raises(ArgumentError) { directory_runfiles.rlocation(nil) } + end + + def test_absolute_path_returned_unchanged + assert_equal '/already/absolute.txt', directory_runfiles.rlocation('/already/absolute.txt') + end + + def test_non_normalized_paths_raise + ['../foo', 'foo/../bar', './foo', 'foo/./bar', 'foo/.', 'foo//bar'].each do |path| + assert_raises(ArgumentError, "expected #{path.inspect} to raise") do + directory_runfiles.rlocation(path) + end + end + end + + def test_absolute_without_drive_letter_raises + assert_raises(ArgumentError) { directory_runfiles.rlocation('\\foo') } + end + + def test_create_picks_manifest_mode_when_manifest_file_set + r = Bazel::Runfiles.create('RUNFILES_MANIFEST_FILE' => @manifest) + assert_equal '/abs/a.txt', r.rlocation('_main/a.txt') + end + + def test_create_picks_directory_mode_when_dir_set + r = Bazel::Runfiles.create('RUNFILES_DIR' => @dir) + assert_equal File.join(@dir, '_main/a.txt'), r.rlocation('_main/a.txt') + end + + def test_create_prefers_manifest_over_directory + r = Bazel::Runfiles.create( + 'RUNFILES_MANIFEST_FILE' => @manifest, + 'RUNFILES_DIR' => @dir + ) + assert_equal '/abs/a.txt', r.rlocation('_main/a.txt') + end +end