summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCharlie Savage <cfis@savagexi.com>2025-06-05 23:30:22 -0700
committergit <svn-admin@ruby-lang.org>2025-10-17 08:24:02 +0000
commit5ced99dddb384762a79c9aa07de130e460479f06 (patch)
tree2d41c24d9078d8e081676acbc55d4d76eeddfe9f
parentc2860bff88cc4ba692273a5bd2393fa03b78db9a (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.rb2
-rw-r--r--lib/rubygems/ext/cmake_builder.rb103
-rw-r--r--test/rubygems/test_gem_ext_cmake_builder.rb95
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