scint 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,9 +13,10 @@ module Scint
13
13
  # Lookup order (first match wins):
14
14
  # 1. Inline credentials in the request URI itself (user:pass@host)
15
15
  # 2. Credentials registered at runtime (from Gemfile source: URIs)
16
- # 3. Scint config: $XDG_CONFIG_HOME/scint/credentials
17
- # 4. Bundler config: ~/.bundle/config
18
- # 5. Environment variables (BUNDLE_HOST__NAME format)
16
+ # 3. Bundler local config: $BUNDLE_APP_CONFIG/config or ./.bundle/config
17
+ # 4. Scint config: $XDG_CONFIG_HOME/scint/credentials
18
+ # 5. Bundler global config: ~/.bundle/config
19
+ # 6. Environment variables (BUNDLE_HOST__NAME / BUNDLE_HTTPS://... format)
19
20
  #
20
21
  # All config files use Bundler's key format:
21
22
  # BUNDLE_PKGS__SHOPIFY__IO: "token:secret"
@@ -80,7 +81,7 @@ module Scint
80
81
  end
81
82
 
82
83
  # 2–5. Registered + config files + env
83
- auth = lookup_host(uri.host)
84
+ auth = lookup_uri(uri)
84
85
  return nil unless auth
85
86
 
86
87
  user, password = auth.split(":", 2)
@@ -99,27 +100,35 @@ module Scint
99
100
 
100
101
  private
101
102
 
102
- def lookup_host(host)
103
- return nil unless host
103
+ def lookup_uri(uri)
104
+ return nil unless uri&.host
104
105
 
105
106
  # 2. Runtime-registered (from Gemfile inline URIs)
106
- registered = @mutex.synchronize { @registered[host] }
107
+ registered = @mutex.synchronize { @registered[uri.host] }
107
108
  return registered if registered
108
109
 
109
- # 3–4. Config files (scint, then bundler)
110
- key = self.class.key_for_host(host)
111
- val = @file_config[key]
112
- return val if val
110
+ # 3–5. Config files (bundler local, scint, bundler global)
111
+ keys = self.class.keys_for_uri_lookup(uri)
112
+ keys.each do |key|
113
+ val = @file_config[key]
114
+ return val if val
115
+ end
116
+
117
+ # 6. Environment variable
118
+ keys.each do |key|
119
+ val = ENV[key]
120
+ return val if val
121
+ end
113
122
 
114
- # 5. Environment variable
115
- ENV[key]
123
+ nil
116
124
  end
117
125
 
118
126
  def load_config_files
119
127
  config = {}
120
128
  # Load in reverse priority (later overrides earlier)
121
- load_yaml_into(config, bundler_config_path)
129
+ load_yaml_into(config, bundler_global_config_path)
122
130
  load_yaml_into(config, scint_credentials_path)
131
+ load_yaml_into(config, bundler_local_config_path)
123
132
  config
124
133
  end
125
134
 
@@ -137,7 +146,17 @@ module Scint
137
146
  File.join(xdg, "scint", "credentials")
138
147
  end
139
148
 
140
- def bundler_config_path
149
+ def bundler_local_config_path
150
+ app_config = ENV["BUNDLE_APP_CONFIG"]
151
+ dir = if app_config && !app_config.empty?
152
+ app_config
153
+ else
154
+ File.join(Dir.pwd, ".bundle")
155
+ end
156
+ File.join(dir, "config")
157
+ end
158
+
159
+ def bundler_global_config_path
141
160
  File.join(Dir.home, ".bundle", "config")
142
161
  end
143
162
 
@@ -149,5 +168,49 @@ module Scint
149
168
  key.upcase!
150
169
  "BUNDLE_#{key}"
151
170
  end
171
+
172
+ def self.key_for_uri_string(uri_string)
173
+ key = uri_string.to_s.dup
174
+ key.gsub!(".", "__")
175
+ key.gsub!("-", "___")
176
+ key.upcase!
177
+ "BUNDLE_#{key}"
178
+ end
179
+
180
+ def self.key_for_source_uri(uri)
181
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
182
+ keys_for_uri_lookup(uri).find { |key| key != key_for_host(uri.host) }
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def self.keys_for_uri_lookup(uri)
188
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
189
+ keys = []
190
+
191
+ normalized = "#{uri.scheme || 'https'}://#{uri.host}"
192
+ default_port = (uri.scheme == "http" ? 80 : 443)
193
+ normalized += ":#{uri.port}" if uri.port && uri.port != default_port
194
+
195
+ path = uri.path.to_s
196
+ path = "/" if path.empty?
197
+ path = "/#{path}" unless path.start_with?("/")
198
+ path += "/" unless path.end_with?("/")
199
+
200
+ segments = path.split("/").reject(&:empty?)
201
+ candidate_paths = ["/"]
202
+ unless segments.empty?
203
+ 1.upto(segments.length) do |i|
204
+ candidate_paths << "/#{segments.first(i).join('/')}/"
205
+ end
206
+ end
207
+
208
+ candidate_paths.reverse_each do |candidate_path|
209
+ keys << key_for_uri_string("#{normalized}#{candidate_path}")
210
+ end
211
+
212
+ keys << key_for_host(uri.host) if uri.host
213
+ keys.compact.uniq
214
+ end
152
215
  end
153
216
  end
@@ -110,7 +110,9 @@ module Scint
110
110
 
111
111
  next unless container.method_defined?(original_name) || container.private_method_defined?(original_name)
112
112
 
113
- container.send(:alias_method, method_name, original_name)
113
+ with_silenced_warnings do
114
+ container.send(:alias_method, method_name, original_name)
115
+ end
114
116
  container.send(:remove_method, original_name)
115
117
 
116
118
  visibility = entry[:visibility]
@@ -146,12 +148,21 @@ module Scint
146
148
  visibility = method_visibility(container, method_name)
147
149
 
148
150
  container.send(:alias_method, original_name, method_name)
149
- container.send(:define_method, method_name) do |*args, **kwargs, &block|
150
- Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
151
- if kwargs.empty?
152
- send(original_name, *args, &block)
153
- else
154
- send(original_name, *args, **kwargs, &block)
151
+ if container.instance_methods(false).include?(method_name) ||
152
+ container.private_instance_methods(false).include?(method_name) ||
153
+ container.protected_instance_methods(false).include?(method_name)
154
+ container.send(:remove_method, method_name)
155
+ elsif container.method_defined?(method_name) || container.private_method_defined?(method_name) || container.protected_method_defined?(method_name)
156
+ container.send(:undef_method, method_name)
157
+ end
158
+ with_silenced_warnings do
159
+ container.send(:define_method, method_name) do |*args, **kwargs, &block|
160
+ Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
161
+ if kwargs.empty?
162
+ send(original_name, *args, &block)
163
+ else
164
+ send(original_name, *args, **kwargs, &block)
165
+ end
155
166
  end
156
167
  end
157
168
 
@@ -173,6 +184,14 @@ module Scint
173
184
  nil
174
185
  end
175
186
 
187
+ def with_silenced_warnings
188
+ verbose = $VERBOSE
189
+ $VERBOSE = nil
190
+ yield
191
+ ensure
192
+ $VERBOSE = verbose
193
+ end
194
+
176
195
  def disable_unlocked
177
196
  return unless @enabled || @log_io
178
197
 
@@ -54,7 +54,13 @@ module Scint
54
54
  current_uri = URI.parse(location)
55
55
  next
56
56
  else
57
- raise NetworkError, "HTTP #{response.code} for #{uri}: #{response.message}"
57
+ raise NetworkError.new(
58
+ http_error_message(current_uri, response),
59
+ uri: current_uri.to_s,
60
+ http_status: response.code.to_i,
61
+ response_headers: response_headers_hash(response),
62
+ response_body: response.body.to_s,
63
+ )
58
64
  end
59
65
  end
60
66
 
@@ -108,6 +114,24 @@ module Scint
108
114
  http
109
115
  end
110
116
  end
117
+
118
+ def http_error_message(uri, response)
119
+ message = "HTTP #{response.code} for #{uri}: #{response.message}"
120
+ body = response.body.to_s
121
+ return message if body.empty?
122
+
123
+ excerpt = body.gsub(/\s+/, " ").strip
124
+ return message if excerpt.empty?
125
+
126
+ excerpt = "#{excerpt[0, 277]}..." if excerpt.length > 280
127
+ "#{message} -- #{excerpt}"
128
+ end
129
+
130
+ def response_headers_hash(response)
131
+ headers = {}
132
+ response.each_header { |k, v| headers[k] = v }
133
+ headers
134
+ end
111
135
  end
112
136
  end
113
137
  end
@@ -4,41 +4,59 @@ require_relative "fetcher"
4
4
  require_relative "../worker_pool"
5
5
  require_relative "../platform"
6
6
  require_relative "../errors"
7
+ require "uri"
7
8
 
8
9
  module Scint
9
10
  module Downloader
10
11
  class Pool
11
12
  MAX_RETRIES = 3
12
13
  BACKOFF_BASE = 0.5 # seconds
14
+ DEFAULT_PER_HOST_LIMIT = 4
13
15
 
14
16
  attr_reader :size
15
17
 
16
- def initialize(size: nil, on_progress: nil, credentials: nil)
18
+ def initialize(size: nil, on_progress: nil, credentials: nil, per_host_limit: DEFAULT_PER_HOST_LIMIT)
17
19
  @size = size || [Platform.cpu_count * 2, 50].min
18
20
  @on_progress = on_progress
19
21
  @credentials = credentials
22
+ @per_host_limit = [per_host_limit.to_i, 1].max
20
23
  @fetchers = {} # thread_id => Fetcher
21
24
  @fetcher_mutex = Thread::Mutex.new
25
+ @host_slots = Hash.new(0)
26
+ @host_waiters = {}
27
+ @host_mutex = Thread::Mutex.new
22
28
  end
23
29
 
24
30
  # Download a single URI to dest_path with retry logic.
25
31
  # Returns { path:, size: }
26
32
  def download(uri, dest_path, checksum: nil)
27
- retries = 0
28
- begin
29
- fetcher = thread_fetcher
30
- fetcher.fetch(uri, dest_path, checksum: checksum)
31
- rescue NetworkError, Errno::ECONNRESET, Errno::ECONNREFUSED,
32
- Errno::ETIMEDOUT, Net::ReadTimeout, Net::OpenTimeout,
33
- SocketError, IOError => e
34
- retries += 1
35
- if retries <= MAX_RETRIES
36
- sleep(BACKOFF_BASE * (2**(retries - 1)))
37
- # Reset connection on retry
38
- reset_thread_fetcher
39
- retry
33
+ with_host_slot(uri) do
34
+ retries = 0
35
+ begin
36
+ fetcher = thread_fetcher
37
+ fetcher.fetch(uri, dest_path, checksum: checksum)
38
+ rescue NetworkError, Errno::ECONNRESET, Errno::ECONNREFUSED,
39
+ Errno::ETIMEDOUT, Net::ReadTimeout, Net::OpenTimeout,
40
+ SocketError, IOError => e
41
+ retries += 1
42
+ if retries <= MAX_RETRIES
43
+ sleep(BACKOFF_BASE * (2**(retries - 1)))
44
+ # Reset connection on retry
45
+ reset_thread_fetcher
46
+ retry
47
+ end
48
+ if e.is_a?(NetworkError)
49
+ raise NetworkError.new(
50
+ "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}",
51
+ uri: (e.uri || uri.to_s),
52
+ http_status: e.http_status,
53
+ response_headers: e.response_headers,
54
+ response_body: e.response_body,
55
+ )
56
+ end
57
+
58
+ raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
40
59
  end
41
- raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
42
60
  end
43
61
  end
44
62
 
@@ -107,6 +125,40 @@ module Scint
107
125
  old&.close
108
126
  end
109
127
  end
128
+
129
+ def with_host_slot(uri)
130
+ key = host_slot_key(uri)
131
+ return yield unless key
132
+
133
+ waiter = nil
134
+ @host_mutex.synchronize do
135
+ waiter = (@host_waiters[key] ||= Thread::ConditionVariable.new)
136
+ while @host_slots[key] >= @per_host_limit
137
+ waiter.wait(@host_mutex)
138
+ end
139
+ @host_slots[key] += 1
140
+ end
141
+
142
+ begin
143
+ yield
144
+ ensure
145
+ @host_mutex.synchronize do
146
+ @host_slots[key] -= 1 if @host_slots[key] > 0
147
+ waiter.broadcast
148
+ end
149
+ end
150
+ end
151
+
152
+ def host_slot_key(uri)
153
+ parsed = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
154
+ return nil unless parsed.host
155
+
156
+ scheme = parsed.scheme || "https"
157
+ port = parsed.port || (scheme == "https" ? 443 : 80)
158
+ "#{scheme}://#{parsed.host}:#{port}"
159
+ rescue URI::InvalidURIError
160
+ nil
161
+ end
110
162
  end
111
163
  end
112
164
  end
data/lib/scint/errors.rb CHANGED
@@ -26,6 +26,16 @@ module Scint
26
26
  end
27
27
 
28
28
  class NetworkError < BundlerError
29
+ attr_reader :uri, :http_status, :response_headers, :response_body
30
+
31
+ def initialize(message = nil, uri: nil, http_status: nil, response_headers: nil, response_body: nil)
32
+ super(message)
33
+ @uri = uri
34
+ @http_status = http_status
35
+ @response_headers = response_headers
36
+ @response_body = response_body
37
+ end
38
+
29
39
  def status_code
30
40
  7
31
41
  end
data/lib/scint/fs.rb CHANGED
@@ -73,11 +73,49 @@ module Scint
73
73
  hardlink_tree(src_dir, dst_dir)
74
74
  end
75
75
 
76
+ # Clone many source directories into one destination parent directory.
77
+ # This is significantly faster than one process per gem on large warm
78
+ # installs because it batches cp invocations while preserving CoW/reflink.
79
+ # Returns the number of source trees requested.
80
+ def clone_many_trees(src_dirs, dst_parent, chunk_size: 64)
81
+ dst_parent = dst_parent.to_s
82
+ mkdir_p(dst_parent)
83
+
84
+ sources = Array(src_dirs).map(&:to_s).uniq
85
+ sources.select! { |src| Dir.exist?(src) }
86
+ return 0 if sources.empty?
87
+
88
+ copied = 0
89
+ sources.each_slice([chunk_size.to_i, 1].max) do |slice|
90
+ pending = slice.reject do |src|
91
+ Dir.exist?(File.join(dst_parent, File.basename(src)))
92
+ end
93
+ next if pending.empty?
94
+
95
+ ok = false
96
+ if Platform.macos?
97
+ ok = system("cp", "-cR", *pending, dst_parent, [:out, :err] => File::NULL)
98
+ elsif Platform.linux?
99
+ ok = system("cp", "--reflink=always", "-R", *pending, dst_parent, [:out, :err] => File::NULL)
100
+ end
101
+
102
+ unless ok
103
+ pending.each do |src|
104
+ clone_tree(src, File.join(dst_parent, File.basename(src)))
105
+ end
106
+ end
107
+
108
+ copied += pending.length
109
+ end
110
+
111
+ copied
112
+ end
113
+
76
114
  # Recursively hardlink all files from src_dir into dst_dir.
77
115
  # Directory structure is recreated; files are hardlinked.
78
116
  def hardlink_tree(src_dir, dst_dir)
79
- src_dir = src_dir.to_s
80
- dst_dir = dst_dir.to_s
117
+ src_dir = File.expand_path(src_dir.to_s)
118
+ dst_dir = File.expand_path(dst_dir.to_s)
81
119
  raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
82
120
  mkdir_p(dst_dir)
83
121
 
@@ -88,6 +126,10 @@ module Scint
88
126
  Dir.each_child(src_root) do |entry|
89
127
  src_path = File.join(src_root, entry)
90
128
  dst_path = File.join(dst_root, entry)
129
+
130
+ # Guard against recursive copy when destination is nested under source.
131
+ next if dst_dir == src_path || dst_dir.start_with?("#{src_path}/")
132
+
91
133
  stat = File.lstat(src_path)
92
134
 
93
135
  if stat.directory?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "dependency"
4
+ require_relative "../source/path"
4
5
 
5
6
  module Scint
6
7
  module Gemfile
@@ -134,6 +135,11 @@ module Scint
134
135
  source_opts[:path] = path_val
135
136
  end
136
137
 
138
+ # Internal/source metadata used by lockfile generation.
139
+ source_opts[:glob] = options.delete(:glob) if options.key?(:glob)
140
+ source_opts[:gemspec_generated] = options.delete(:gemspec_generated) if options.key?(:gemspec_generated)
141
+ source_opts[:gemspec_primary] = options.delete(:gemspec_primary) if options.key?(:gemspec_primary)
142
+
137
143
  if options[:source]
138
144
  source_opts[:source] = options.delete(:source)
139
145
  end
@@ -213,24 +219,39 @@ module Scint
213
219
  instance_eval(contents, expanded, 1)
214
220
  end
215
221
 
216
- def ruby(version, **opts)
217
- @ruby_version = version.to_s
222
+ def ruby(*versions, **opts)
223
+ version_parts = versions.flatten.compact.map(&:to_s)
224
+ @ruby_version = version_parts.join(", ") unless version_parts.empty?
218
225
  end
219
226
 
220
227
  def gemspec(opts = {})
221
228
  path = opts[:path] || "."
222
229
  name = opts[:name]
230
+ glob = opts[:glob] || Scint::Source::Path::DEFAULT_GLOB
223
231
  dir = File.expand_path(path, File.dirname(@gemfile_path))
224
- gemspecs = Dir.glob(File.join(dir, "{,*}.gemspec"))
232
+ gemspecs = Dir.glob(File.join(dir, glob)).sort
225
233
  # Just record we have a gemspec source -- full spec loading is
226
234
  # deferred to the resolver/installer.
227
235
  gemspecs.each do |gs|
228
236
  spec_name = File.basename(gs, ".gemspec")
229
237
  next if name && spec_name != name
230
- gem(spec_name, path: dir)
238
+ gem(
239
+ spec_name,
240
+ path: File.dirname(gs),
241
+ glob: glob,
242
+ gemspec_generated: true,
243
+ gemspec_primary: File.expand_path(File.dirname(gs)) == dir,
244
+ )
231
245
  end
232
246
  end
233
247
 
248
+ def install_if(*conditions, &blk)
249
+ raise GemfileError, "install_if requires a block" unless block_given?
250
+ return unless conditions.all? { |condition| condition_truthy?(condition) }
251
+
252
+ yield
253
+ end
254
+
234
255
  # Silently ignore plugin declarations
235
256
  def plugin(*args); end
236
257
 
@@ -247,6 +268,12 @@ module Scint
247
268
 
248
269
  private
249
270
 
271
+ def condition_truthy?(condition)
272
+ return condition.call if condition.respond_to?(:call)
273
+
274
+ !!condition
275
+ end
276
+
250
277
  def add_default_git_sources
251
278
  git_source(:github) do |repo_name|
252
279
  if repo_name =~ %r{\Ahttps://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}
@@ -3,6 +3,7 @@
3
3
  require_relative "../fs"
4
4
  require_relative "../platform"
5
5
  require_relative "../errors"
6
+ require_relative "../spec_utils"
6
7
  require "open3"
7
8
 
8
9
  module Scint
@@ -16,7 +17,7 @@ module Scint
16
17
  # abi_key: e.g. "ruby-3.3.0-arm64-darwin24" (defaults to Platform.abi_key)
17
18
  def build(prepared_gem, bundle_path, cache_layout, abi_key: Platform.abi_key, compile_slots: 1, output_tail: nil)
18
19
  spec = prepared_gem.spec
19
- ruby_dir = ruby_install_dir(bundle_path)
20
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
20
21
  build_ruby_dir = cache_layout.install_ruby_dir
21
22
 
22
23
  # Check global extension cache first
@@ -28,17 +29,28 @@ module Scint
28
29
 
29
30
  # Build in a temp dir, then cache
30
31
  src_dir = prepared_gem.extracted_path
31
- ext_dirs = find_extension_dirs(src_dir)
32
- raise ExtensionBuildError, "No extension directories found for #{spec.name}" if ext_dirs.empty?
33
-
34
32
  FS.with_tempdir("scint-ext") do |tmpdir|
35
- build_dir = File.join(tmpdir, "build")
33
+ # Stage the full gem source tree in an isolated workspace.
34
+ # Many extconf scripts use paths like ../../vendor relative to ext/,
35
+ # which only work when the full gem layout is preserved.
36
+ staged_src_dir = File.join(tmpdir, "source")
37
+ FS.clone_tree(src_dir, staged_src_dir)
38
+
39
+ ext_dirs = find_extension_dirs(staged_src_dir)
40
+ raise ExtensionBuildError, "No extension directories found for #{spec.name}" if ext_dirs.empty?
41
+
42
+ build_root = File.join(tmpdir, "build")
36
43
  install_dir = File.join(tmpdir, "install")
37
- FS.mkdir_p(build_dir)
44
+ FS.mkdir_p(build_root)
38
45
  FS.mkdir_p(install_dir)
39
46
 
40
- ext_dirs.each do |ext_dir|
41
- compile_extension(ext_dir, build_dir, install_dir, src_dir, spec, build_ruby_dir, compile_slots, output_tail)
47
+ ext_dirs.each_with_index do |ext_dir, idx|
48
+ # Keep isolated build trees per extension directory. Some gems
49
+ # invoke multiple CMake projects under ext/ and CMake caches are
50
+ # source-tree specific.
51
+ ext_build_dir = File.join(build_root, idx.to_s)
52
+ FS.mkdir_p(ext_build_dir)
53
+ compile_extension(ext_dir, ext_build_dir, install_dir, staged_src_dir, spec, build_ruby_dir, compile_slots, output_tail)
42
54
  end
43
55
 
44
56
  # Write marker
@@ -65,7 +77,7 @@ module Scint
65
77
  spec = prepared_gem.spec
66
78
  return false unless cached_build_available?(spec, cache_layout, abi_key: abi_key)
67
79
 
68
- ruby_dir = ruby_install_dir(bundle_path)
80
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
69
81
  cached_ext = cache_layout.ext_path(spec, abi_key)
70
82
  link_extensions(cached_ext, ruby_dir, spec, abi_key)
71
83
  true
@@ -120,9 +132,18 @@ module Scint
120
132
  dirs << File.dirname(path)
121
133
  end
122
134
 
123
- # CMakeLists.txt in ext/
124
- Dir.glob(File.join(gem_dir, "ext", "**", "CMakeLists.txt")).each do |path|
125
- dir = File.dirname(path)
135
+ # CMakeLists.txt in ext/. Keep only top-level CMake roots, so vendored
136
+ # subprojects (e.g. deps/*) are not built standalone.
137
+ cmake_dirs = Dir.glob(File.join(gem_dir, "ext", "**", "CMakeLists.txt"))
138
+ .map { |path| File.dirname(path) }
139
+ .uniq
140
+ .sort_by { |dir| [dir.length, dir] }
141
+ cmake_roots = []
142
+ cmake_dirs.each do |dir|
143
+ next if cmake_roots.any? { |root| dir.start_with?("#{root}/") }
144
+ cmake_roots << dir
145
+ end
146
+ cmake_roots.each do |dir|
126
147
  dirs << dir unless dirs.include?(dir)
127
148
  end
128
149
 
@@ -141,7 +162,7 @@ module Scint
141
162
  env = build_env(gem_dir, build_ruby_dir, make_jobs)
142
163
 
143
164
  if File.exist?(File.join(ext_dir, "extconf.rb"))
144
- compile_extconf(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
165
+ compile_extconf(ext_dir, gem_dir, build_dir, install_dir, env, make_jobs, output_tail)
145
166
  elsif File.exist?(File.join(ext_dir, "CMakeLists.txt"))
146
167
  compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
147
168
  elsif File.exist?(File.join(ext_dir, "Rakefile"))
@@ -151,13 +172,18 @@ module Scint
151
172
  end
152
173
  end
153
174
 
154
- def compile_extconf(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
175
+ def compile_extconf(ext_dir, gem_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
176
+ # Build in-place within the staged ext directory so extconf scripts
177
+ # that navigate relative paths (../../vendor, ../..) behave like
178
+ # Bundler's install layout.
179
+ _ = gem_dir
180
+ _ = build_dir
155
181
  run_cmd(env, RbConfig.ruby, File.join(ext_dir, "extconf.rb"),
156
182
  "--with-opt-dir=#{RbConfig::CONFIG["prefix"]}",
157
- chdir: build_dir, output_tail: output_tail)
158
- run_cmd(env, "make", "-j#{make_jobs}", "-C", build_dir, output_tail: output_tail)
183
+ chdir: ext_dir, output_tail: output_tail)
184
+ run_cmd(env, "make", "-j#{make_jobs}", "-C", ext_dir, output_tail: output_tail)
159
185
  run_cmd(env, "make", "install", "DESTDIR=", "sitearchdir=#{install_dir}", "sitelibdir=#{install_dir}",
160
- chdir: build_dir, output_tail: output_tail)
186
+ chdir: ext_dir, output_tail: output_tail)
161
187
  end
162
188
 
163
189
  def compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
@@ -208,9 +234,21 @@ module Scint
208
234
  ext_install_dir = File.join(ruby_dir, "extensions",
209
235
  Platform.gem_arch, Platform.extension_api_version,
210
236
  spec_full_name(spec))
211
- return if Dir.exist?(ext_install_dir)
237
+ FS.clone_tree(cached_ext, ext_install_dir) unless Dir.exist?(ext_install_dir)
238
+ sync_extension_artifacts_into_gem(ext_install_dir, ruby_dir, spec)
239
+ end
212
240
 
213
- FS.clone_tree(cached_ext, ext_install_dir)
241
+ def sync_extension_artifacts_into_gem(ext_install_dir, ruby_dir, spec)
242
+ gem_dir = File.join(ruby_dir, "gems", spec_full_name(spec))
243
+ lib_dir = File.join(gem_dir, "lib")
244
+ return unless Dir.exist?(lib_dir)
245
+
246
+ Dir.glob(File.join(ext_install_dir, "**", "*.{so,bundle,dll,dylib}")).each do |artifact|
247
+ rel = artifact.delete_prefix("#{ext_install_dir}/")
248
+ dest = File.join(lib_dir, rel)
249
+ FS.mkdir_p(File.dirname(dest))
250
+ FS.clonefile(artifact, dest)
251
+ end
214
252
  end
215
253
 
216
254
  def build_env(gem_dir, build_ruby_dir, make_jobs)
@@ -279,21 +317,13 @@ module Scint
279
317
  end
280
318
 
281
319
  def spec_full_name(spec)
282
- name = spec.name
283
- version = spec.version
284
- plat = spec.respond_to?(:platform) ? spec.platform : nil
285
- base = "#{name}-#{version}"
286
- (plat.nil? || plat.to_s == "ruby" || plat.to_s.empty?) ? base : "#{base}-#{plat}"
287
- end
288
-
289
- def ruby_install_dir(bundle_path)
290
- File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
320
+ SpecUtils.full_name(spec)
291
321
  end
292
322
 
293
323
  private_class_method :find_extension_dirs, :compile_extension,
294
324
  :compile_extconf, :compile_cmake, :compile_rake,
295
- :find_rake_executable, :link_extensions, :build_env, :run_cmd,
296
- :spec_full_name, :ruby_install_dir, :prebuilt_missing_for_ruby?
325
+ :find_rake_executable, :link_extensions, :sync_extension_artifacts_into_gem,
326
+ :build_env, :run_cmd, :prebuilt_missing_for_ruby?
297
327
  end
298
328
  end
299
329
  end