diff options
Diffstat (limited to 'lib/rubygems/ext')
| -rw-r--r-- | lib/rubygems/ext/build_error.rb | 4 | ||||
| -rw-r--r-- | lib/rubygems/ext/builder.rb | 208 | ||||
| -rw-r--r-- | lib/rubygems/ext/cargo_builder.rb | 349 | ||||
| -rw-r--r-- | lib/rubygems/ext/cargo_builder/link_flag_converter.rb | 27 | ||||
| -rw-r--r-- | lib/rubygems/ext/cmake_builder.rb | 107 | ||||
| -rw-r--r-- | lib/rubygems/ext/configure_builder.rb | 18 | ||||
| -rw-r--r-- | lib/rubygems/ext/ext_conf_builder.rb | 117 | ||||
| -rw-r--r-- | lib/rubygems/ext/rake_builder.rb | 40 |
8 files changed, 690 insertions, 180 deletions
diff --git a/lib/rubygems/ext/build_error.rb b/lib/rubygems/ext/build_error.rb index 0b3c17a9a0..0329c1eec3 100644 --- a/lib/rubygems/ext/build_error.rb +++ b/lib/rubygems/ext/build_error.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true + ## # Raised when there is an error while building extensions. +require_relative "../exceptions" + class Gem::Ext::BuildError < Gem::InstallError end - diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb index a1619c97d7..e00cf159da 100644 --- a/lib/rubygems/ext/builder.rb +++ b/lib/rubygems/ext/builder.rb @@ -1,24 +1,18 @@ # frozen_string_literal: true + #-- # Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. # All rights reserved. # See LICENSE.txt for permissions. #++ -require 'rubygems/user_interaction' -require 'thread' +require_relative "../user_interaction" class Gem::Ext::Builder - include Gem::UserInteraction - ## - # The builder shells-out to run various commands after changing the - # directory. This means multiple installations cannot be allowed to build - # extensions in parallel as they may change each other's directories leading - # to broken extensions or failed installations. - - CHDIR_MUTEX = Mutex.new # :nodoc: + class NoMakefileError < Gem::InstallError + end attr_accessor :build_args # :nodoc: @@ -27,111 +21,175 @@ class Gem::Ext::Builder $1.downcase end - def self.make(dest_path, results) - unless File.exist? 'Makefile' then - raise Gem::InstallError, 'Makefile not found' + def self.make(dest_path, results, make_dir = Dir.pwd, sitedir = nil, targets = ["clean", "", "install"], + target_rbconfig: Gem.target_rbconfig, n_jobs: nil) + unless File.exist? File.join(make_dir, "Makefile") + # No makefile exists, nothing to do. + raise NoMakefileError, "No Makefile found in #{make_dir}" end # try to find make program from Ruby configure arguments first - RbConfig::CONFIG['configure_args'] =~ /with-make-prog\=(\w+)/ - make_program = ENV['MAKE'] || ENV['make'] || $1 - unless make_program then - make_program = (/mswin/ =~ RUBY_PLATFORM) ? 'nmake' : 'make' + target_rbconfig["configure_args"] =~ /with-make-prog\=(\w+)/ + make_program_name = ENV["MAKE"] || ENV["make"] || $1 + make_program_name ||= RUBY_PLATFORM.include?("mswin") ? "nmake" : "make" + make_program = shellsplit(make_program_name) + + is_nmake = /\bnmake/i.match?(make_program_name) + # The installation of the bundled gems is failed when DESTDIR is empty in mswin platform. + destdir = !is_nmake || ENV["DESTDIR"] && ENV["DESTDIR"] != "" ? format("DESTDIR=%s", ENV["DESTDIR"]) : "" + + # nmake doesn't support parallel build + unless is_nmake + have_make_arguments = make_program.size > 1 + + if !have_make_arguments && !ENV["MAKEFLAGS"] && n_jobs + make_program << "-j#{n_jobs}" + end end - destdir = '"DESTDIR=%s"' % ENV['DESTDIR'] if RUBY_VERSION > '2.0' + env = [destdir] - ['clean', '', 'install'].each do |target| + if sitedir + env << format("sitearchdir=%s", sitedir) + env << format("sitelibdir=%s", sitedir) + end + + targets.each do |target| # Pass DESTDIR via command line to override what's in MAKEFLAGS cmd = [ - make_program, - destdir, - target - ].join(' ').rstrip + *make_program, + *env, + target, + ].reject(&:empty?) begin - run(cmd, results, "make #{target}".rstrip) + run(cmd, results, "make #{target}".rstrip, make_dir) rescue Gem::InstallError - raise unless target == 'clean' # ignore clean failure + raise unless target == "clean" # ignore clean failure end end end - def self.redirector - '2>&1' + def self.ruby + # Gem.ruby is quoted if it contains whitespace + cmd = shellsplit(Gem.ruby) + + # This load_path is only needed when running rubygems test without a proper installation. + # Prepending it in a normal installation will cause problem with order of $LOAD_PATH. + # Therefore only add load_path if it is not present in the default $LOAD_PATH. + load_path = File.expand_path("../..", __dir__) + case load_path + when RbConfig::CONFIG["sitelibdir"], RbConfig::CONFIG["vendorlibdir"], RbConfig::CONFIG["rubylibdir"] + cmd + else + cmd << "-I#{load_path}" + end end - def self.run(command, results, command_name = nil) + def self.run(command, results, command_name = nil, dir = Dir.pwd, env = {}) verbose = Gem.configuration.really_verbose begin - # TODO use Process.spawn when ruby 1.8 support is dropped. - rubygems_gemdeps, ENV['RUBYGEMS_GEMDEPS'] = ENV['RUBYGEMS_GEMDEPS'], nil + rubygems_gemdeps = ENV["RUBYGEMS_GEMDEPS"] + ENV["RUBYGEMS_GEMDEPS"] = nil if verbose - puts("current directory: #{Dir.pwd}") - puts(command) - system(command) - else - results << "current directory: #{Dir.pwd}" - results << command - results << `#{command} #{redirector}` + puts("current directory: #{dir}") + p(command) + end + results << "current directory: #{dir}" + results << shelljoin(command) + + require "open3" + # Set $SOURCE_DATE_EPOCH for the subprocess. + build_env = { "SOURCE_DATE_EPOCH" => Gem.source_date_epoch_string }.merge(env) + output, status = begin + Open3.popen2e(build_env, *command, chdir: dir) do |stdin, stdouterr, wait_thread| + stdin.close + output = String.new + while line = stdouterr.gets + output << line + if verbose + print line + end + end + [output, wait_thread.value] + end + rescue StandardError => error + raise Gem::InstallError, "#{command_name || class_name} failed#{error.message}" + end + unless verbose + results << output end ensure - ENV['RUBYGEMS_GEMDEPS'] = rubygems_gemdeps + ENV["RUBYGEMS_GEMDEPS"] = rubygems_gemdeps end - unless $?.success? then + unless status.success? results << "Building has failed. See above output for more information on the failure." if verbose + end + yield(status, results) if block_given? + + unless status.success? exit_reason = - if $?.exited? then - ", exit code #{$?.exitstatus}" - elsif $?.signaled? then - ", uncaught signal #{$?.termsig}" + if status.exited? + ", exit code #{status.exitstatus}" + elsif status.signaled? + ", uncaught signal #{status.termsig}" end raise Gem::InstallError, "#{command_name || class_name} failed#{exit_reason}" end end + def self.shellsplit(command) + require "shellwords" + + Shellwords.split(command) + end + + def self.shelljoin(command) + require "shellwords" + + Shellwords.join(command) + end + ## # Creates a new extension builder for +spec+. If the +spec+ does not yet # have build arguments, saved, set +build_args+ which is an ARGV-style # array. - def initialize spec, build_args = spec.build_args + def initialize(spec, build_args = spec.build_args, target_rbconfig = Gem.target_rbconfig, build_jobs = nil) @spec = spec @build_args = build_args @gem_dir = spec.full_gem_path - - @ran_rake = nil + @target_rbconfig = target_rbconfig + @build_jobs = build_jobs end ## # Chooses the extension builder class for +extension+ - def builder_for extension # :nodoc: + def builder_for(extension) # :nodoc: case extension when /extconf/ then Gem::Ext::ExtConfBuilder when /configure/ then Gem::Ext::ConfigureBuilder when /rakefile/i, /mkrf_conf/i then - @ran_rake = true Gem::Ext::RakeBuilder when /CMakeLists.txt/ then - Gem::Ext::CmakeBuilder + Gem::Ext::CmakeBuilder.new + when /Cargo.toml/ then + Gem::Ext::CargoBuilder.new else - extension_dir = File.join @gem_dir, File.dirname(extension) - - message = "No builder for extension '#{extension}'" - build_error extension_dir, message + build_error("No builder for extension '#{extension}'") end end ## - # Logs the build +output+ in +build_dir+, then raises Gem::Ext::BuildError. + # Logs the build +output+, then raises Gem::Ext::BuildError. - def build_error build_dir, output, backtrace = nil # :nodoc: + def build_error(output, backtrace = nil) # :nodoc: gem_make_out = write_gem_make_out output message = <<-EOF @@ -146,32 +204,27 @@ EOF raise Gem::Ext::BuildError, message, backtrace end - def build_extension extension, dest_path # :nodoc: + def build_extension(extension, dest_path) # :nodoc: results = [] - extension ||= '' # I wish I knew why this line existed + builder = builder_for(extension) + extension_dir = - File.expand_path File.join @gem_dir, File.dirname(extension) + File.expand_path File.join(@gem_dir, File.dirname(extension)) lib_dir = File.join @spec.full_gem_path, @spec.raw_require_paths.first - builder = builder_for extension - begin FileUtils.mkdir_p dest_path - CHDIR_MUTEX.synchronize do - Dir.chdir extension_dir do - results = builder.build(extension, @gem_dir, dest_path, - results, @build_args, lib_dir) + results = builder.build(extension, dest_path, + results, @build_args, lib_dir, extension_dir, @target_rbconfig, n_jobs: @build_jobs) - verbose { results.join("\n") } - end - end + verbose { results.join("\n") } write_gem_make_out results.join "\n" - rescue => e + rescue StandardError => e results << e.message - build_error extension_dir, results.join("\n"), $@ + build_error(results.join("\n"), $@) end end @@ -185,19 +238,16 @@ EOF if @build_args.empty? say "Building native extensions. This could take a while..." else - say "Building native extensions with: '#{@build_args.join ' '}'" + say "Building native extensions with: '#{@build_args.join " "}'" say "This could take a while..." end dest_path = @spec.extension_dir + require "fileutils" FileUtils.rm_f @spec.gem_build_complete_path - @ran_rake = false # only run rake once - @spec.extensions.each do |extension| - break if @ran_rake - build_extension extension, dest_path end @@ -207,15 +257,15 @@ EOF ## # Writes +output+ to gem_make.out in the extension install directory. - def write_gem_make_out output # :nodoc: - destination = File.join @spec.extension_dir, 'gem_make.out' + def write_gem_make_out(output) # :nodoc: + destination = File.join @spec.extension_dir, "gem_make.out" FileUtils.mkdir_p @spec.extension_dir - open destination, 'wb' do |io| io.puts output end + File.open destination, "wb" do |io| + io.puts output + end destination end - end - diff --git a/lib/rubygems/ext/cargo_builder.rb b/lib/rubygems/ext/cargo_builder.rb new file mode 100644 index 0000000000..516459dd60 --- /dev/null +++ b/lib/rubygems/ext/cargo_builder.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +# This class is used by rubygems to build Rust extensions. It is a thin-wrapper +# over the `cargo rustc` command which takes care of building Rust code in a way +# that Ruby can use. +class Gem::Ext::CargoBuilder < Gem::Ext::Builder + attr_accessor :spec, :runner, :profile + + def initialize + require_relative "../command" + require_relative "cargo_builder/link_flag_converter" + + @runner = self.class.method(:run) + @profile = :release + end + + def build(extension, dest_path, results, args = [], lib_dir = nil, cargo_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + require "tempfile" + require "fileutils" + + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for Rust extensions. Ignoring" + end + + # Where's the Cargo.toml of the crate we're building + cargo_toml = File.join(cargo_dir, "Cargo.toml") + # What's the crate's name + crate_name = cargo_crate_name(cargo_dir, cargo_toml, results) + + begin + # Create a tmp dir to do the build in + tmp_dest = Dir.mktmpdir(".gem.", cargo_dir) + + # Run the build + cmd = cargo_command(cargo_toml, tmp_dest, args, crate_name) + runner.call(cmd, results, "cargo", cargo_dir, build_env) + + # Where do we expect Cargo to write the compiled library + dylib_path = cargo_dylib_path(tmp_dest, crate_name) + + # Helpful error if we didn't find the compiled library + raise DylibNotFoundError, tmp_dest unless File.exist?(dylib_path) + + # Cargo and Ruby differ on how the library should be named, rename from + # what Cargo outputs to what Ruby expects + dlext_name = "#{crate_name}.#{makefile_config("DLEXT")}" + dlext_path = File.join(File.dirname(dylib_path), dlext_name) + FileUtils.cp(dylib_path, dlext_path) + + nesting = extension_nesting(extension) + + if Gem.install_extension_in_lib && lib_dir + nested_lib_dir = File.join(lib_dir, nesting) + FileUtils.mkdir_p nested_lib_dir + FileUtils.cp_r dlext_path, nested_lib_dir, remove_destination: true + end + + # move to final destination + nested_dest_path = File.join(dest_path, nesting) + FileUtils.mkdir_p nested_dest_path + FileUtils.cp_r dlext_path, nested_dest_path, remove_destination: true + ensure + # clean up intermediary build artifacts + FileUtils.rm_rf tmp_dest if tmp_dest + end + + results + end + + def build_env + build_env = rb_config_env + build_env["RUBY_STATIC"] = "true" if ruby_static? && ENV.key?("RUBY_STATIC") + cfg = "--cfg=rb_sys_gem --cfg=rubygems --cfg=rubygems_#{Gem::VERSION.tr(".", "_")}" + build_env["RUSTFLAGS"] = [ENV["RUSTFLAGS"], cfg].compact.join(" ") + build_env + end + + def cargo_command(cargo_toml, dest_path, args = [], crate_name = nil) + cmd = [] + cmd += [cargo, "rustc"] + cmd += ["--crate-type", "cdylib"] + cmd += ["--target", ENV["CARGO_BUILD_TARGET"]] if ENV["CARGO_BUILD_TARGET"] + cmd += ["--target-dir", dest_path] + cmd += ["--manifest-path", cargo_toml] + cmd += ["--lib"] + cmd += ["--profile", profile.to_s] + cmd += ["--locked"] + cmd += Gem::Command.build_args + cmd += args + cmd += ["--"] + cmd += [*cargo_rustc_args(dest_path, crate_name)] + cmd + end + + private + + def cargo + ENV.fetch("CARGO", "cargo") + end + + # returns the directory nesting of the extension, ignoring the first part, so + # "ext/foo/bar/Cargo.toml" becomes "foo/bar" + def extension_nesting(extension) + parts = extension.to_s.split(Regexp.union([File::SEPARATOR, File::ALT_SEPARATOR].compact)) + + parts = parts.each_with_object([]) do |segment, final| + next if segment == "." + if segment == ".." + raise Gem::InstallError, "extension outside of gem root" if final.empty? + next final.pop + end + final << segment + end + + File.join(parts[1...-1]) + end + + def rb_config_env + result = {} + RbConfig::CONFIG.each {|k, v| result["RBCONFIG_#{k}"] = v } + result + end + + def cargo_rustc_args(dest_dir, crate_name) + [ + *linker_args, + *mkmf_libpath, + *rustc_dynamic_linker_flags(dest_dir, crate_name), + *rustc_lib_flags(dest_dir), + *platform_specific_rustc_args(dest_dir), + ] + end + + def platform_specific_rustc_args(dest_dir, flags = []) + if mingw_target? + # On mingw platforms, mkmf adds libruby to the linker flags + flags += libruby_args(dest_dir) + + # Make sure ALSR is used on mingw + # see https://github.com/rust-lang/rust/pull/75406/files + flags += ["-C", "link-arg=-Wl,--dynamicbase"] + flags += ["-C", "link-arg=-Wl,--disable-auto-image-base"] + + # If the gem is installed on a host with build tools installed, but is + # run on one that isn't the missing libraries will cause the extension + # to fail on start. + flags += ["-C", "link-arg=-static-libgcc"] + elsif darwin_target? + # Ventura does not always have this flag enabled + flags += ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + end + + flags + end + + # We want to use the same linker that Ruby uses, so that the linker flags from + # mkmf work properly. + def linker_args + cc_flag = self.class.shellsplit(makefile_config("CC")) + # Avoid to ccache like tool from Rust build + # see https://github.com/ruby/rubygems/pull/8521#issuecomment-2689854359 + # ex. CC="ccache gcc" or CC="sccache clang --any --args" + cc_flag.shift if cc_flag.size >= 2 && !cc_flag[1].start_with?("-") + linker = cc_flag.shift + link_args = cc_flag.flat_map {|a| ["-C", "link-arg=#{a}"] } + + return mswin_link_args if linker == "cl" + + ["-C", "linker=#{linker}", *link_args] + end + + def mswin_link_args + args = [] + args += ["-l", makefile_config("LIBRUBYARG_SHARED").chomp(".lib")] + args += split_flags("LIBS").flat_map {|lib| ["-l", lib.chomp(".lib")] } + args += split_flags("LOCAL_LIBS").flat_map {|lib| ["-l", lib.chomp(".lib")] } + args + end + + def libruby_args(dest_dir) + libs = makefile_config(ruby_static? ? "LIBRUBYARG_STATIC" : "LIBRUBYARG_SHARED") + raw_libs = self.class.shellsplit(libs) + raw_libs.flat_map {|l| ldflag_to_link_modifier(l) } + end + + def ruby_static? + return true if %w[1 true].include?(ENV["RUBY_STATIC"]) + + makefile_config("ENABLE_SHARED") == "no" + end + + def cargo_dylib_path(dest_path, crate_name) + so_ext = RbConfig::CONFIG["SOEXT"] + prefix = so_ext == "dll" ? "" : "lib" + path_parts = [dest_path] + path_parts << ENV["CARGO_BUILD_TARGET"] if ENV["CARGO_BUILD_TARGET"] + path_parts += ["release", "#{prefix}#{crate_name}.#{so_ext}"] + File.join(*path_parts) + end + + def cargo_crate_name(cargo_dir, manifest_path, results) + require "open3" + Gem.load_yaml + + output, status = + begin + Open3.capture2e(cargo, "metadata", "--no-deps", "--format-version", "1", chdir: cargo_dir) + rescue StandardError => error + raise Gem::InstallError, "cargo metadata failed #{error.message}" + end + + unless status.success? + if Gem.configuration.really_verbose + puts output + else + results << output + end + + exit_reason = + if status.exited? + ", exit code #{status.exitstatus}" + elsif status.signaled? + ", uncaught signal #{status.termsig}" + end + + raise Gem::InstallError, "cargo metadata failed#{exit_reason}" + end + + # cargo metadata output is specified as json + require "json" + metadata = JSON.parse(output) + package = metadata["packages"].find {|pkg| normalize_path(pkg["manifest_path"]) == manifest_path } + unless package + found = metadata["packages"].map {|md| "#{md["name"]} at #{md["manifest_path"]}" } + raise Gem::InstallError, <<-EOF +failed to determine cargo package name + +looking for: #{manifest_path} + +found: +#{found.join("\n")} +EOF + end + package["name"].tr("-", "_") + end + + def normalize_path(path) + return path unless File::ALT_SEPARATOR + + path.tr(File::ALT_SEPARATOR, File::SEPARATOR) + end + + def rustc_dynamic_linker_flags(dest_dir, crate_name) + split_flags("DLDFLAGS"). + filter_map {|arg| maybe_resolve_ldflag_variable(arg, dest_dir, crate_name) }. + flat_map {|arg| ldflag_to_link_modifier(arg) } + end + + def rustc_lib_flags(dest_dir) + split_flags("LIBS").flat_map {|arg| ldflag_to_link_modifier(arg) } + end + + def split_flags(var) + self.class.shellsplit(RbConfig::CONFIG.fetch(var, "")) + end + + def ldflag_to_link_modifier(arg) + LinkFlagConverter.convert(arg) + end + + def msvc_target? + makefile_config("target_os").include?("msvc") + end + + def darwin_target? + makefile_config("target_os").include?("darwin") + end + + def mingw_target? + makefile_config("target_os").include?("mingw") + end + + def win_target? + target_platform = RbConfig::CONFIG["target_os"] + !!Gem::WIN_PATTERNS.find {|r| target_platform =~ r } + end + + # Interpolate substitution vars in the arg (i.e. $(DEFFILE)) + def maybe_resolve_ldflag_variable(input_arg, dest_dir, crate_name) + var_matches = input_arg.match(/\$\((\w+)\)/) + + return input_arg unless var_matches + + var_name = var_matches[1] + + return input_arg if var_name.nil? || var_name.chomp.empty? + + case var_name + # On windows, it is assumed that mkmf has setup an exports file for the + # extension, so we have to create one ourselves. + when "DEFFILE" + write_deffile(dest_dir, crate_name) + else + RbConfig::CONFIG[var_name] + end + end + + def write_deffile(dest_dir, crate_name) + deffile_path = File.join(dest_dir, "#{crate_name}-#{RbConfig::CONFIG["arch"]}.def") + export_prefix = makefile_config("EXPORT_PREFIX") || "" + + File.open(deffile_path, "w") do |f| + f.puts "EXPORTS" + f.puts "#{export_prefix.strip}Init_#{crate_name}" + end + + deffile_path + end + + # Corresponds to $(LIBPATH) in mkmf + def mkmf_libpath + ["-L", "native=#{makefile_config("libdir")}"] + end + + def makefile_config(var_name) + val = RbConfig::MAKEFILE_CONFIG[var_name] + + return unless val + + RbConfig.expand(val.dup) + end + + # Error raised when no cdylib artifact was created + class DylibNotFoundError < StandardError + def initialize(dir) + files = Dir.glob(File.join(dir, "**", "*")).map {|f| "- #{f}" }.join "\n" + + super <<~MSG + Dynamic library not found for Rust extension (in #{dir}) + + Make sure you set "crate-type" in Cargo.toml to "cdylib" + + Found files: + #{files} + MSG + end + end +end diff --git a/lib/rubygems/ext/cargo_builder/link_flag_converter.rb b/lib/rubygems/ext/cargo_builder/link_flag_converter.rb new file mode 100644 index 0000000000..e4d196cb10 --- /dev/null +++ b/lib/rubygems/ext/cargo_builder/link_flag_converter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Gem::Ext::CargoBuilder < Gem::Ext::Builder + # Converts Ruby link flags into something cargo understands + class LinkFlagConverter + FILTERED_PATTERNS = [ + /compress-debug-sections/, # Not supported by all linkers, and not required for Rust + ].freeze + + def self.convert(arg) + return [] if FILTERED_PATTERNS.any? {|p| p.match?(arg) } + + case arg.chomp + when /^-L\s*(.+)$/ + ["-L", "native=#{$1}"] + when /^--library=(\w+\S+)$/, /^-l\s*(\w+\S+)$/ + ["-l", $1] + when /^-l\s*([^:\s])+/ # -lfoo, but not -l:libfoo.a + ["-l", $1] + when /^-F\s*(.*)$/ + ["-l", "framework=#{$1}"] + else + ["-C", "link-args=#{arg}"] + end + end + end +end diff --git a/lib/rubygems/ext/cmake_builder.rb b/lib/rubygems/ext/cmake_builder.rb index efa3bd1d88..e660ed558b 100644 --- a/lib/rubygems/ext/cmake_builder.rb +++ b/lib/rubygems/ext/cmake_builder.rb @@ -1,17 +1,110 @@ # frozen_string_literal: true -require 'rubygems/command' + +# This builder creates extensions defined using CMake. Its is invoked if a Gem's spec file +# sets the `extension` property to a string that contains `CMakeLists.txt`. +# +# In general, CMake projects are built in two steps: +# +# * configure +# * build +# +# The builder follow this convention. First it runs a configuration step and then it runs a build step. +# +# CMake projects can be quite configurable - it is likely you will want to specify options when +# installing a gem. To pass options to CMake specify them after `--` in the gem install command. For example: +# +# gem install <gem_name> -- --preset <preset_name> +# +# Note that options are ONLY sent to the configure step - it is not currently possible to specify +# options for the build step. If this becomes and issue then the CMake builder can be updated to +# support build options. +# +# Useful options to know are: +# +# -G to specify a generator (-G Ninja is recommended) +# -D<CMAKE_VARIABLE> to set a CMake variable (for example -DCMAKE_BUILD_TYPE=Release) +# --preset <preset_name> to use a preset +# +# If the Gem author provides presets, via CMakePresets.json file, you will likely want to use one of them. +# If not, you may wish to specify a generator. Ninja is recommended because it can build projects in parallel +# and thus much faster than building them serially like Make does. class Gem::Ext::CmakeBuilder < Gem::Ext::Builder - def self.build(extension, directory, dest_path, results, args=[], lib_dir=nil) - unless File.exist?('Makefile') then - cmd = "cmake . -DCMAKE_INSTALL_PREFIX=#{dest_path}" - cmd << " #{Gem::Command.build_args.join ' '}" unless Gem::Command.build_args.empty? + attr_accessor :runner, :profile + def initialize + @runner = self.class.method(:run) + @profile = :release + end - run cmd, results + def build(extension, dest_path, results, args = [], lib_dir = nil, cmake_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for CMake extensions. Ignoring" end - make dest_path, results + # Figure the build dir + build_dir = File.join(cmake_dir, "build") + + # Check if the gem defined presets + check_presets(cmake_dir, args, results) + + # Configure + configure(cmake_dir, build_dir, dest_path, args, results) + + # Compile + compile(cmake_dir, build_dir, args, results) results end + + def configure(cmake_dir, build_dir, install_dir, args, results) + cmd = ["cmake", + cmake_dir, + "-B", + build_dir, + "-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=#{install_dir}", # Windows + "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=#{install_dir}", # Not Windows + *Gem::Command.build_args, + *args] + + runner.call(cmd, results, "cmake_configure", cmake_dir) + end + + def compile(cmake_dir, build_dir, args, results) + cmd = ["cmake", + "--build", + build_dir.to_s, + "--config", + @profile.to_s] + + runner.call(cmd, results, "cmake_compile", cmake_dir) + end + + private + + def check_presets(cmake_dir, args, results) + # Return if the user specified a preset + return unless args.grep(/--preset/i).empty? + + cmd = ["cmake", + "--list-presets"] + + presets = Array.new + begin + runner.call(cmd, presets, "cmake_presets", cmake_dir) + + # Remove the first two lines of the array which is the current_directory and the command + # that was run + presets = presets[2..].join + results << <<~EOS + The gem author provided a list of presets that can be used to build the gem. To use a preset specify it on the command line: + + gem install <gem_name> -- --preset <preset_name> + + #{presets} + EOS + rescue Gem::InstallError + # Do nothing, CMakePresets.json was not included in the Gem + end + end end diff --git a/lib/rubygems/ext/configure_builder.rb b/lib/rubygems/ext/configure_builder.rb index 8b42bf7ee9..230b214b3c 100644 --- a/lib/rubygems/ext/configure_builder.rb +++ b/lib/rubygems/ext/configure_builder.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + #-- # Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. # All rights reserved. @@ -6,19 +7,20 @@ #++ class Gem::Ext::ConfigureBuilder < Gem::Ext::Builder + def self.build(extension, dest_path, results, args = [], lib_dir = nil, configure_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for configure-based extensions. Ignoring" + end - def self.build(extension, directory, dest_path, results, args=[], lib_dir=nil) - unless File.exist?('Makefile') then - cmd = "sh ./configure --prefix=#{dest_path}" - cmd << " #{args.join ' '}" unless args.empty? + unless File.exist?(File.join(configure_dir, "Makefile")) + cmd = ["sh", "./configure", "--prefix=#{dest_path}", *args] - run cmd, results + run cmd, results, class_name, configure_dir end - make dest_path, results + make dest_path, results, configure_dir, target_rbconfig: target_rbconfig, n_jobs: n_jobs results end - end - diff --git a/lib/rubygems/ext/ext_conf_builder.rb b/lib/rubygems/ext/ext_conf_builder.rb index 965b728278..822454355d 100644 --- a/lib/rubygems/ext/ext_conf_builder.rb +++ b/lib/rubygems/ext/ext_conf_builder.rb @@ -1,94 +1,81 @@ # frozen_string_literal: true + #-- # Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. # All rights reserved. # See LICENSE.txt for permissions. #++ -require 'fileutils' -require 'tempfile' - class Gem::Ext::ExtConfBuilder < Gem::Ext::Builder - FileEntry = FileUtils::Entry_ # :nodoc: + def self.build(extension, dest_path, results, args = [], lib_dir = nil, extension_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + require "fileutils" + require "tempfile" - def self.build(extension, directory, dest_path, results, args=[], lib_dir=nil) - tmp_dest = Dir.mktmpdir(".gem.", ".") + tmp_dest = Dir.mktmpdir(".gem.", extension_dir) # Some versions of `mktmpdir` return absolute paths, which will break make - # if the paths contain spaces. However, on Ruby 1.9.x on Windows, relative - # paths cause all C extension builds to fail. - # - # As such, we convert to a relative path unless we are using Ruby 1.9.x on - # Windows. This means that when using Ruby 1.9.x on Windows, paths with - # spaces do not work. + # if the paths contain spaces. # - # Details: https://github.com/rubygems/rubygems/issues/977#issuecomment-171544940 - # - # TODO: Make this unconditional when rubygems no longer supports Ruby 1.9.x. - tmp_dest = get_relative_path(tmp_dest) unless Gem.win_platform? && RUBY_VERSION <= '2.0' - - Tempfile.open %w"siteconf .rb", "." do |siteconf| - siteconf.puts "require 'rbconfig'" - siteconf.puts "dest_path = #{tmp_dest.dump}" - %w[sitearchdir sitelibdir].each do |dir| - siteconf.puts "RbConfig::MAKEFILE_CONFIG['#{dir}'] = dest_path" - siteconf.puts "RbConfig::CONFIG['#{dir}'] = dest_path" - end - - siteconf.close - - destdir = ENV["DESTDIR"] - - begin - cmd = [Gem.ruby, "-r", get_relative_path(siteconf.path), File.basename(extension), *args].join ' ' - - begin - run cmd, results - ensure - if File.exist? 'mkmf.log' - unless $?.success? then - results << "To see why this extension failed to compile, please check" \ - " the mkmf.log which can be found here:\n" - results << " " + File.join(dest_path, 'mkmf.log') + "\n" - end - FileUtils.mv 'mkmf.log', dest_path + # As such, we convert to a relative path. + tmp_dest_relative = get_relative_path(tmp_dest.clone, extension_dir) + + destdir = ENV["DESTDIR"] + + begin + cmd = ruby << File.basename(extension) + cmd << "--target-rbconfig=#{target_rbconfig.path}" if target_rbconfig.path + cmd.push(*args) + + run(cmd, results, class_name, extension_dir) do |s, r| + mkmf_log = File.join(extension_dir, "mkmf.log") + if File.exist? mkmf_log + unless s.success? + r << "To see why this extension failed to compile, please check" \ + " the mkmf.log which can be found here:\n" + r << " " + File.join(dest_path, "mkmf.log") + "\n" end - siteconf.unlink + FileUtils.mv mkmf_log, dest_path end + end - ENV["DESTDIR"] = nil + ENV["DESTDIR"] = nil - make dest_path, results + make dest_path, results, extension_dir, tmp_dest_relative, target_rbconfig: target_rbconfig, n_jobs: n_jobs - if tmp_dest - # TODO remove in RubyGems 3 - if Gem.install_extension_in_lib and lib_dir then - FileUtils.mkdir_p lib_dir - entries = Dir.entries(tmp_dest) - %w[. ..] - entries = entries.map { |entry| File.join tmp_dest, entry } - FileUtils.cp_r entries, lib_dir, :remove_destination => true - end + full_tmp_dest = File.join(extension_dir, tmp_dest_relative) - FileEntry.new(tmp_dest).traverse do |ent| - destent = ent.class.new(dest_path, ent.rel) - destent.exist? or FileUtils.mv(ent.path, destent.path) - end - end - ensure - ENV["DESTDIR"] = destdir - siteconf.close! + is_cross_compiling = target_rbconfig["platform"] != RbConfig::CONFIG["platform"] + # Do not copy extension libraries by default when cross-compiling + # not to conflict with the one already built for the host platform. + if Gem.install_extension_in_lib && lib_dir && !is_cross_compiling + FileUtils.mkdir_p lib_dir + entries = Dir.entries(full_tmp_dest) - %w[. ..] + entries = entries.map {|entry| File.join full_tmp_dest, entry } + FileUtils.cp_r entries, lib_dir, remove_destination: true end + + FileUtils::Entry_.new(full_tmp_dest).traverse do |ent| + destent = ent.class.new(dest_path, ent.rel) + destent.exist? || FileUtils.mv(ent.path, destent.path) + end + + make dest_path, results, extension_dir, tmp_dest_relative, ["clean"], target_rbconfig: target_rbconfig + ensure + ENV["DESTDIR"] = destdir end results + rescue Gem::Ext::Builder::NoMakefileError => error + results << error.message + results << "Skipping make for #{extension} as no Makefile was found." + # We are good, do not re-raise the error. ensure FileUtils.rm_rf tmp_dest if tmp_dest end - private - def self.get_relative_path(path) - path[0..Dir.pwd.length-1] = '.' if path.start_with?(Dir.pwd) + def self.get_relative_path(path, base) + path[0..base.length - 1] = "." if path.start_with?(base) path end - end diff --git a/lib/rubygems/ext/rake_builder.rb b/lib/rubygems/ext/rake_builder.rb index 4b3534bf35..d702d7f339 100644 --- a/lib/rubygems/ext/rake_builder.rb +++ b/lib/rubygems/ext/rake_builder.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + #-- # Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. # All rights reserved. @@ -6,32 +7,31 @@ #++ class Gem::Ext::RakeBuilder < Gem::Ext::Builder - - def self.build(extension, directory, dest_path, results, args=[], lib_dir=nil) - if File.basename(extension) =~ /mkrf_conf/i then - cmd = "#{Gem.ruby} #{File.basename extension}".dup - cmd << " #{args.join " "}" unless args.empty? - run cmd, results + def self.build(extension, dest_path, results, args = [], lib_dir = nil, extension_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for Rake extensions. Ignoring" end - # Deal with possible spaces in the path, e.g. C:/Program Files - dest_path = '"' + dest_path.to_s + '"' if dest_path.to_s.include?(' ') - - rake = ENV['rake'] - - rake ||= begin - "#{Gem.ruby} -rrubygems #{Gem.bin_path('rake', 'rake')}" - rescue Gem::Exception - end + if /mkrf_conf/i.match?(File.basename(extension)) + run([Gem.ruby, File.basename(extension), *args], results, class_name, extension_dir) + end - rake ||= Gem.default_exec_format % 'rake' + rake = ENV["rake"] - cmd = "#{rake} RUBYARCHDIR=#{dest_path} RUBYLIBDIR=#{dest_path}" # ENV is frozen + if rake + rake = shellsplit(rake) + else + begin + rake = ruby << "-rrubygems" << Gem.bin_path("rake", "rake") + rescue Gem::Exception + rake = [Gem.default_exec_format % "rake"] + end + end - run cmd, results + rake_args = ["RUBYARCHDIR=#{dest_path}", "RUBYLIBDIR=#{dest_path}", *args] + run(rake + rake_args, results, class_name, extension_dir) results end - end - |
