Skip to content

Commit 9e29135

Browse files
committed
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.
1 parent 72407d4 commit 9e29135

File tree

3 files changed

+176
-26
lines changed

3 files changed

+176
-26
lines changed

lib/rubygems/ext/builder.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def builder_for(extension) # :nodoc:
165165
@ran_rake = true
166166
Gem::Ext::RakeBuilder
167167
when /CMakeLists.txt/ then
168-
Gem::Ext::CmakeBuilder
168+
Gem::Ext::CmakeBuilder.new
169169
when /Cargo.toml/ then
170170
Gem::Ext::CargoBuilder.new
171171
else

lib/rubygems/ext/cmake_builder.rb

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,110 @@
11
# frozen_string_literal: true
22

3+
# This builder creates extensions defined using CMake. Its is invoked if a Gem's spec file
4+
# sets the `extension` property to a string that contains `CMakeLists.txt`.
5+
#
6+
# In general, CMake projects are built in two steps:
7+
#
8+
# * configure
9+
# * build
10+
#
11+
# The builder follow this convention. First it runs a configuration step and then it runs a build step.
12+
#
13+
# CMake projects can be quite configurable - it is likely you will want to specify options when
14+
# installing a gem. To pass options to CMake specify them after `--` in the gem install command. For example:
15+
#
16+
# gem install <gem_name> -- --preset <preset_name>
17+
#
18+
# Note that options are ONLY sent to the configure step - it is not currently possible to specify
19+
# options for the build step. If this becomes and issue then the CMake builder can be updated to
20+
# support build options.
21+
#
22+
# Useful options to know are:
23+
#
24+
# -G to specify a generator (-G Ninja is recommended)
25+
# -D<CMAKE_VARIABLE> to set a CMake variable (for example -DCMAKE_BUILD_TYPE=Release)
26+
# --preset <preset_name> to use a preset
27+
#
28+
# If the Gem author provides presets, via CMakePresets.json file, you will likely want to use one of them.
29+
# If not, you may wish to specify a generator. Ninja is recommended because it can build projects in parallel
30+
# and thus much faster than building them serially like Make does.
31+
332
class Gem::Ext::CmakeBuilder < Gem::Ext::Builder
4-
def self.build(extension, dest_path, results, args=[], lib_dir=nil, cmake_dir=Dir.pwd,
5-
target_rbconfig=Gem.target_rbconfig)
33+
attr_accessor :runner, :profile
34+
35+
def initialize
36+
@runner = self.class.method(:run)
37+
@profile = :release
38+
end
39+
40+
def build(extension, dest_path, results, args = [], lib_dir = nil, cmake_dir = Dir.pwd, target_rbconfig = Gem.target_rbconfig)
641
if target_rbconfig.path
742
warn "--target-rbconfig is not yet supported for CMake extensions. Ignoring"
843
end
944

10-
unless File.exist?(File.join(cmake_dir, "Makefile"))
11-
require_relative "../command"
12-
cmd = ["cmake", ".", "-DCMAKE_INSTALL_PREFIX=#{dest_path}", *Gem::Command.build_args]
45+
# Figure the build dir
46+
build_dir = File.join(cmake_dir, "build")
1347

14-
run cmd, results, class_name, cmake_dir
15-
end
48+
# Check if the gem defined presets
49+
check_presets(cmake_dir, args, results)
50+
51+
# Configure
52+
configure(cmake_dir, build_dir, dest_path, args, results)
1653

17-
make dest_path, results, cmake_dir, target_rbconfig: target_rbconfig
54+
# Compile
55+
compile(cmake_dir, build_dir, args, results)
1856

1957
results
2058
end
59+
60+
def configure(cmake_dir, build_dir, install_dir, args, results)
61+
cmd = ["cmake",
62+
cmake_dir,
63+
"-B",
64+
build_dir,
65+
"-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=#{install_dir}", # Windows
66+
"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=#{install_dir}", # Not Windows
67+
*Gem::Command.build_args,
68+
*args]
69+
70+
runner.call(cmd, results, "cmake_configure", cmake_dir)
71+
end
72+
73+
def compile(cmake_dir, build_dir, args, results)
74+
cmd = ["cmake",
75+
"--build",
76+
build_dir.to_s,
77+
"--config",
78+
@profile.to_s]
79+
80+
runner.call(cmd, results, "cmake_compile", cmake_dir)
81+
end
82+
83+
private
84+
85+
def check_presets(cmake_dir, args, results)
86+
# Return if the user specified a preset
87+
return unless args.grep(/--preset/i).empty?
88+
89+
cmd = ["cmake",
90+
"--list-presets"]
91+
92+
presets = Array.new
93+
begin
94+
runner.call(cmd, presets, "cmake_presets", cmake_dir)
95+
96+
# Remove the first two lines of the array which is the current_directory and the command
97+
# that was run
98+
presets = presets[2..].join
99+
results << <<~EOS
100+
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:
101+
102+
gem install <gem_name> -- --preset <preset_name>
103+
104+
#{presets}
105+
EOS
106+
rescue Gem::InstallError
107+
# Do nothing, CMakePresets.json was not included in the Gem
108+
end
109+
end
21110
end

test/rubygems/test_gem_ext_cmake_builder.rb

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def setup
2929
def test_self_build
3030
File.open File.join(@ext, "CMakeLists.txt"), "w" do |cmakelists|
3131
cmakelists.write <<-EO_CMAKE
32-
cmake_minimum_required(VERSION 3.5)
32+
cmake_minimum_required(VERSION 3.26)
3333
project(self_build NONE)
3434
install (FILES test.txt DESTINATION bin)
3535
EO_CMAKE
@@ -39,46 +39,107 @@ def test_self_build
3939

4040
output = []
4141

42-
Gem::Ext::CmakeBuilder.build nil, @dest_path, output, [], nil, @ext
42+
builder = Gem::Ext::CmakeBuilder.new
43+
builder.build nil, @dest_path, output, [], @dest_path, @ext
4344

4445
output = output.join "\n"
4546

46-
assert_match(/^cmake \. -DCMAKE_INSTALL_PREFIX\\=#{Regexp.escape @dest_path}/, output)
47+
assert_match(/^current directory: #{Regexp.escape @ext}/, output)
48+
assert_match(/cmake.*-DCMAKE_RUNTIME_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output)
49+
assert_match(/cmake.*-DCMAKE_LIBRARY_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output)
50+
assert_match(/#{Regexp.escape @ext}/, output)
51+
end
52+
53+
def test_self_build_presets
54+
File.open File.join(@ext, "CMakeLists.txt"), "w" do |cmakelists|
55+
cmakelists.write <<-EO_CMAKE
56+
cmake_minimum_required(VERSION 3.26)
57+
project(self_build NONE)
58+
install (FILES test.txt DESTINATION bin)
59+
EO_CMAKE
60+
end
61+
62+
File.open File.join(@ext, "CMakePresets.json"), "w" do |presets|
63+
presets.write <<-EO_CMAKE
64+
{
65+
"version": 6,
66+
"configurePresets": [
67+
{
68+
"name": "debug",
69+
"displayName": "Debug",
70+
"generator": "Ninja",
71+
"binaryDir": "build/debug",
72+
"cacheVariables": {
73+
"CMAKE_BUILD_TYPE": "Debug"
74+
}
75+
},
76+
{
77+
"name": "release",
78+
"displayName": "Release",
79+
"generator": "Ninja",
80+
"binaryDir": "build/release",
81+
"cacheVariables": {
82+
"CMAKE_BUILD_TYPE": "Release"
83+
}
84+
}
85+
]
86+
}
87+
EO_CMAKE
88+
end
89+
90+
FileUtils.touch File.join(@ext, "test.txt")
91+
92+
output = []
93+
94+
builder = Gem::Ext::CmakeBuilder.new
95+
builder.build nil, @dest_path, output, [], @dest_path, @ext
96+
97+
output = output.join "\n"
98+
99+
assert_match(/The gem author provided a list of presets that can be used to build the gem./, output)
100+
assert_match(/Available configure presets/, output)
101+
assert_match(/\"debug\" - Debug/, output)
102+
assert_match(/\"release\" - Release/, output)
103+
assert_match(/^current directory: #{Regexp.escape @ext}/, output)
104+
assert_match(/cmake.*-DCMAKE_RUNTIME_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output)
105+
assert_match(/cmake.*-DCMAKE_LIBRARY_OUTPUT_DIRECTORY\\=#{Regexp.escape @dest_path}/, output)
47106
assert_match(/#{Regexp.escape @ext}/, output)
48-
assert_contains_make_command "", output
49-
assert_contains_make_command "install", output
50-
assert_match(/test\.txt/, output)
51107
end
52108

53109
def test_self_build_fail
54110
output = []
55111

112+
builder = Gem::Ext::CmakeBuilder.new
56113
error = assert_raise Gem::InstallError do
57-
Gem::Ext::CmakeBuilder.build nil, @dest_path, output, [], nil, @ext
114+
builder.build nil, @dest_path, output, [], @dest_path, @ext
58115
end
59116

60-
output = output.join "\n"
117+
assert_match "cmake_configure failed", error.message
61118

62119
shell_error_msg = /(CMake Error: .*)/
63-
64-
assert_match "cmake failed", error.message
65-
66-
assert_match(/^cmake . -DCMAKE_INSTALL_PREFIX\\=#{Regexp.escape @dest_path}/, output)
120+
output = output.join "\n"
67121
assert_match(/#{shell_error_msg}/, output)
122+
assert_match(/CMake Error: The source directory .* does not appear to contain CMakeLists.txt./, output)
68123
end
69124

70125
def test_self_build_has_makefile
71-
File.open File.join(@ext, "Makefile"), "w" do |makefile|
72-
makefile.puts "all:\n\t@echo ok\ninstall:\n\t@echo ok"
126+
File.open File.join(@ext, "CMakeLists.txt"), "w" do |cmakelists|
127+
cmakelists.write <<-EO_CMAKE
128+
cmake_minimum_required(VERSION 3.26)
129+
project(self_build NONE)
130+
install (FILES test.txt DESTINATION bin)
131+
EO_CMAKE
73132
end
74133

75134
output = []
76135

77-
Gem::Ext::CmakeBuilder.build nil, @dest_path, output, [], nil, @ext
136+
builder = Gem::Ext::CmakeBuilder.new
137+
builder.build nil, @dest_path, output, [], @dest_path, @ext
78138

79139
output = output.join "\n"
80140

81-
assert_contains_make_command "", output
82-
assert_contains_make_command "install", output
141+
# The default generator will create a Makefile in the build directory
142+
makefile = File.join(@ext, "build", "Makefile")
143+
assert(File.exist?(makefile))
83144
end
84145
end

0 commit comments

Comments
 (0)