diff options
| author | Charlie Savage <cfis@savagexi.com> | 2025-06-05 23:30:22 -0700 |
|---|---|---|
| committer | git <svn-admin@ruby-lang.org> | 2025-10-17 08:24:02 +0000 |
| commit | 5ced99dddb384762a79c9aa07de130e460479f06 (patch) | |
| tree | 2d41c24d9078d8e081676acbc55d4d76eeddfe9f | |
| parent | c2860bff88cc4ba692273a5bd2393fa03b78db9a (diff) | |
[rubygems/rubygems] :Revamp CmakeBuilder to fix the issues described in #8572. Specifically:
* Correctly pass command line arguments to CMake
* Call CMake twice - once to configure a project and a second time to build (which is the standard way to use CMake). This fixes the previously incorrect assumption that CMake generates a Make file.
* Update the tests to specify a CMake minimum version of 3.26 (which is already two years old). 3.26 is a bit arbritary but it aligns with Rice, and updates from the ancient 3.5 version being used (which CMake generates a warning message saying stop using it!)
* Update the CMake call to use CMAKE_RUNTIME_OUTPUT_DIRECTORY and CMAKE_LIBRARY_OUTPUT_DIRECTORY to tell CMake to copy compiled binaries to the a Gem's lib directory.
Note the updated builder took inspiration from the Cargo Builder, meaning you first create an instance of CmakeBuilder versus just calling class methods.
https://github.com/rubygems/rubygems/commit/9e248d4679
| -rw-r--r-- | lib/rubygems/ext/builder.rb | 2 | ||||
| -rw-r--r-- | lib/rubygems/ext/cmake_builder.rb | 103 | ||||
| -rw-r--r-- | test/rubygems/test_gem_ext_cmake_builder.rb | 95 |
3 files changed, 175 insertions, 25 deletions
diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb index b47996d092..600a6a5ff6 100644 --- a/lib/rubygems/ext/builder.rb +++ b/lib/rubygems/ext/builder.rb @@ -169,7 +169,7 @@ class Gem::Ext::Builder @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 diff --git a/lib/rubygems/ext/cmake_builder.rb b/lib/rubygems/ext/cmake_builder.rb index c7bfbb8a57..2915568b39 100644 --- a/lib/rubygems/ext/cmake_builder.rb +++ b/lib/rubygems/ext/cmake_builder.rb @@ -1,21 +1,110 @@ # frozen_string_literal: true +# 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, dest_path, results, args = [], lib_dir = nil, cmake_dir = Dir.pwd, + attr_accessor :runner, :profile + def initialize + @runner = self.class.method(:run) + @profile = :release + end + + def build(extension, dest_path, results, args = [], lib_dir = nil, cmake_dir = Dir.pwd, target_rbconfig = Gem.target_rbconfig) if target_rbconfig.path warn "--target-rbconfig is not yet supported for CMake extensions. Ignoring" end - unless File.exist?(File.join(cmake_dir, "Makefile")) - require_relative "../command" - cmd = ["cmake", ".", "-DCMAKE_INSTALL_PREFIX=#{dest_path}", *Gem::Command.build_args] + # Figure the build dir + build_dir = File.join(cmake_dir, "build") - run cmd, results, class_name, cmake_dir - end + # Check if the gem defined presets + check_presets(cmake_dir, args, results) + + # Configure + configure(cmake_dir, build_dir, dest_path, args, results) - make dest_path, results, cmake_dir, target_rbconfig: target_rbconfig + # 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/test/rubygems/test_gem_ext_cmake_builder.rb b/test/rubygems/test_gem_ext_cmake_builder.rb index b4cf8a8443..e2bdedc710 100644 --- a/test/rubygems/test_gem_ext_cmake_builder.rb +++ b/test/rubygems/test_gem_ext_cmake_builder.rb @@ -29,7 +29,7 @@ class TestGemExtCmakeBuilder < Gem::TestCase def test_self_build File.open File.join(@ext, "CMakeLists.txt"), "w" do |cmakelists| cmakelists.write <<-EO_CMAKE -cmake_minimum_required(VERSION 3.5) +cmake_minimum_required(VERSION 3.26) project(self_build NONE) install (FILES test.txt DESTINATION bin) EO_CMAKE @@ -39,46 +39,107 @@ install (FILES test.txt DESTINATION bin) output = [] - Gem::Ext::CmakeBuilder.build nil, @dest_path, output, [], nil, @ext + builder = Gem::Ext::CmakeBuilder.new + builder.build nil, @dest_path, output, [], @dest_path, @ext output = output.join "\n" - assert_match(/^cmake \. -DCMAKE_INSTALL_PREFIX\\=#{Regexp.escape @dest_path}/, output) + assert_match(/^current directory: #{Regexp.escape @ext}/, output) + assert_match(/cmake.*-DCMAKE_RUNTIME_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output) + assert_match(/cmake.*-DCMAKE_LIBRARY_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output) + assert_match(/#{Regexp.escape @ext}/, output) + end + + def test_self_build_presets + File.open File.join(@ext, "CMakeLists.txt"), "w" do |cmakelists| + cmakelists.write <<-EO_CMAKE +cmake_minimum_required(VERSION 3.26) +project(self_build NONE) +install (FILES test.txt DESTINATION bin) + EO_CMAKE + end + + File.open File.join(@ext, "CMakePresets.json"), "w" do |presets| + presets.write <<-EO_CMAKE +{ + "version": 6, + "configurePresets": [ + { + "name": "debug", + "displayName": "Debug", + "generator": "Ninja", + "binaryDir": "build/debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "displayName": "Release", + "generator": "Ninja", + "binaryDir": "build/release", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + } + ] +} + EO_CMAKE + end + + FileUtils.touch File.join(@ext, "test.txt") + + output = [] + + builder = Gem::Ext::CmakeBuilder.new + builder.build nil, @dest_path, output, [], @dest_path, @ext + + output = output.join "\n" + + assert_match(/The gem author provided a list of presets that can be used to build the gem./, output) + assert_match(/Available configure presets/, output) + assert_match(/\"debug\" - Debug/, output) + assert_match(/\"release\" - Release/, output) + assert_match(/^current directory: #{Regexp.escape @ext}/, output) + assert_match(/cmake.*-DCMAKE_RUNTIME_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output) + assert_match(/cmake.*-DCMAKE_LIBRARY_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output) assert_match(/#{Regexp.escape @ext}/, output) - assert_contains_make_command "", output - assert_contains_make_command "install", output - assert_match(/test\.txt/, output) end def test_self_build_fail output = [] + builder = Gem::Ext::CmakeBuilder.new error = assert_raise Gem::InstallError do - Gem::Ext::CmakeBuilder.build nil, @dest_path, output, [], nil, @ext + builder.build nil, @dest_path, output, [], @dest_path, @ext end - output = output.join "\n" + assert_match "cmake_configure failed", error.message shell_error_msg = /(CMake Error: .*)/ - - assert_match "cmake failed", error.message - - assert_match(/^cmake . -DCMAKE_INSTALL_PREFIX\\=#{Regexp.escape @dest_path}/, output) + output = output.join "\n" assert_match(/#{shell_error_msg}/, output) + assert_match(/CMake Error: The source directory .* does not appear to contain CMakeLists.txt./, output) end def test_self_build_has_makefile - File.open File.join(@ext, "Makefile"), "w" do |makefile| - makefile.puts "all:\n\t@echo ok\ninstall:\n\t@echo ok" + File.open File.join(@ext, "CMakeLists.txt"), "w" do |cmakelists| + cmakelists.write <<-EO_CMAKE +cmake_minimum_required(VERSION 3.26) +project(self_build NONE) +install (FILES test.txt DESTINATION bin) + EO_CMAKE end output = [] - Gem::Ext::CmakeBuilder.build nil, @dest_path, output, [], nil, @ext + builder = Gem::Ext::CmakeBuilder.new + builder.build nil, @dest_path, output, [], @dest_path, @ext output = output.join "\n" - assert_contains_make_command "", output - assert_contains_make_command "install", output + # The default generator will create a Makefile in the build directory + makefile = File.join(@ext, "build", "Makefile") + assert(File.exist?(makefile)) end end |
