scint 0.1.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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/FEATURES.md +13 -0
  3. data/README.md +216 -0
  4. data/bin/bundler-vs-scint +233 -0
  5. data/bin/scint +35 -0
  6. data/bin/scint-io-summary +46 -0
  7. data/bin/scint-syscall-trace +41 -0
  8. data/lib/bundler/setup.rb +5 -0
  9. data/lib/bundler.rb +168 -0
  10. data/lib/scint/cache/layout.rb +131 -0
  11. data/lib/scint/cache/metadata_store.rb +75 -0
  12. data/lib/scint/cache/prewarm.rb +192 -0
  13. data/lib/scint/cli/add.rb +85 -0
  14. data/lib/scint/cli/cache.rb +316 -0
  15. data/lib/scint/cli/exec.rb +150 -0
  16. data/lib/scint/cli/install.rb +1047 -0
  17. data/lib/scint/cli/remove.rb +60 -0
  18. data/lib/scint/cli.rb +77 -0
  19. data/lib/scint/commands/exec.rb +17 -0
  20. data/lib/scint/commands/install.rb +17 -0
  21. data/lib/scint/credentials.rb +153 -0
  22. data/lib/scint/debug/io_trace.rb +218 -0
  23. data/lib/scint/debug/sampler.rb +138 -0
  24. data/lib/scint/downloader/fetcher.rb +113 -0
  25. data/lib/scint/downloader/pool.rb +112 -0
  26. data/lib/scint/errors.rb +63 -0
  27. data/lib/scint/fs.rb +119 -0
  28. data/lib/scint/gem/extractor.rb +86 -0
  29. data/lib/scint/gem/package.rb +62 -0
  30. data/lib/scint/gemfile/dependency.rb +30 -0
  31. data/lib/scint/gemfile/editor.rb +93 -0
  32. data/lib/scint/gemfile/parser.rb +275 -0
  33. data/lib/scint/index/cache.rb +166 -0
  34. data/lib/scint/index/client.rb +301 -0
  35. data/lib/scint/index/parser.rb +142 -0
  36. data/lib/scint/installer/extension_builder.rb +264 -0
  37. data/lib/scint/installer/linker.rb +226 -0
  38. data/lib/scint/installer/planner.rb +140 -0
  39. data/lib/scint/installer/preparer.rb +207 -0
  40. data/lib/scint/lockfile/parser.rb +251 -0
  41. data/lib/scint/lockfile/writer.rb +178 -0
  42. data/lib/scint/platform.rb +71 -0
  43. data/lib/scint/progress.rb +579 -0
  44. data/lib/scint/resolver/provider.rb +230 -0
  45. data/lib/scint/resolver/resolver.rb +249 -0
  46. data/lib/scint/runtime/exec.rb +141 -0
  47. data/lib/scint/runtime/setup.rb +45 -0
  48. data/lib/scint/scheduler.rb +392 -0
  49. data/lib/scint/source/base.rb +46 -0
  50. data/lib/scint/source/git.rb +92 -0
  51. data/lib/scint/source/path.rb +70 -0
  52. data/lib/scint/source/rubygems.rb +79 -0
  53. data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
  54. data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
  55. data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
  56. data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
  57. data/lib/scint/vendor/pub_grub/package.rb +43 -0
  58. data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
  59. data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
  60. data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
  61. data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
  62. data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
  63. data/lib/scint/vendor/pub_grub/term.rb +105 -0
  64. data/lib/scint/vendor/pub_grub/version.rb +3 -0
  65. data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
  66. data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
  67. data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
  68. data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
  69. data/lib/scint/vendor/pub_grub.rb +32 -0
  70. data/lib/scint/worker_pool.rb +114 -0
  71. data/lib/scint.rb +87 -0
  72. metadata +116 -0
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "zlib"
6
+ require_relative "cache"
7
+ require_relative "parser"
8
+
9
+ module Scint
10
+ module Index
11
+ # Compact index client for rubygems.org (or any compact index source).
12
+ # Thread-safe. Uses ETag/Range for efficient updates.
13
+ class Client
14
+ ACCEPT_ENCODING = "gzip"
15
+ USER_AGENT = "scint/0.1.0"
16
+ DEFAULT_TIMEOUT = 15
17
+
18
+ attr_reader :source_uri
19
+
20
+ def initialize(source_uri, cache_dir: nil, credentials: nil)
21
+ @source_uri = source_uri.to_s.chomp("/")
22
+ @uri = URI.parse(@source_uri)
23
+ @cache = Cache.new(cache_dir || default_cache_dir)
24
+ @parser = Parser.new
25
+ @credentials = credentials
26
+ @mutex = Thread::Mutex.new
27
+ @fetched = {} # track which endpoints we've already fetched this session
28
+ @connections = Thread::Queue.new
29
+ end
30
+
31
+ # Fetch the list of all gem names from this source.
32
+ def fetch_names
33
+ data = fetch_endpoint("names")
34
+ @parser.parse_names(data)
35
+ end
36
+
37
+ # Fetch the versions list. Returns { name => [[name, version, platform], ...] }
38
+ # Also populates info checksums for cache validation.
39
+ def fetch_versions
40
+ data = fetch_endpoint("versions")
41
+ @parser.parse_versions(data)
42
+ end
43
+
44
+ # Fetch info for a single gem. Returns parsed info entries.
45
+ # Uses binary cache when checksum matches.
46
+ def fetch_info(gem_name)
47
+ checksums = @parser.info_checksums
48
+ checksum = checksums[gem_name]
49
+
50
+ # Try binary cache first
51
+ if checksum && !checksum.empty?
52
+ cached = @cache.read_binary_info(gem_name, checksum)
53
+ return cached if cached
54
+ end
55
+
56
+ # Check if local info file matches remote checksum
57
+ if checksum && !checksum.empty? && @cache.info_fresh?(gem_name, checksum)
58
+ data = @cache.info(gem_name)
59
+ else
60
+ data = fetch_info_endpoint(gem_name)
61
+ end
62
+
63
+ return [] unless data
64
+
65
+ result = @parser.parse_info(gem_name, data)
66
+
67
+ # Write binary cache
68
+ if checksum && !checksum.empty? && !result.empty?
69
+ @cache.write_binary_info(gem_name, checksum, result)
70
+ end
71
+
72
+ result
73
+ end
74
+
75
+ # Prefetch info for multiple gems concurrently.
76
+ # Uses a thread pool for parallel HTTP requests.
77
+ def prefetch(gem_names, worker_count: nil)
78
+ names = Array(gem_names).uniq
79
+ return if names.empty?
80
+
81
+ # Ensure versions are fetched first (populates checksums)
82
+ fetch_versions unless @parser.info_checksums.any?
83
+
84
+ # Filter to names that actually need fetching
85
+ checksums = @parser.info_checksums
86
+ to_fetch = names.select do |name|
87
+ checksum = checksums[name]
88
+ if checksum && !checksum.empty?
89
+ cached = @cache.read_binary_info(name, checksum)
90
+ !cached
91
+ else
92
+ true
93
+ end
94
+ end.reject do |name|
95
+ checksum = checksums[name]
96
+ checksum && !checksum.empty? && @cache.info_fresh?(name, checksum)
97
+ end
98
+
99
+ return if to_fetch.empty?
100
+
101
+ pool_size = worker_count || [to_fetch.size, 8].min
102
+ results = {}
103
+ queue = Thread::Queue.new
104
+ to_fetch.each { |n| queue.push(n) }
105
+ pool_size.times { queue.push(:done) }
106
+
107
+ threads = pool_size.times.map do
108
+ Thread.new do
109
+ while (name = queue.pop) != :done
110
+ begin
111
+ data = fetch_info_endpoint(name)
112
+ if data
113
+ parsed = @parser.parse_info(name, data)
114
+ checksum = checksums[name]
115
+ if checksum && !checksum.empty? && !parsed.empty?
116
+ @cache.write_binary_info(name, checksum, parsed)
117
+ end
118
+ @mutex.synchronize { results[name] = parsed }
119
+ end
120
+ rescue StandardError => e
121
+ $stderr.puts "prefetch warning: #{name}: #{e.message}" if ENV["SCINT_DEBUG"]
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ threads.each(&:join)
128
+ results
129
+ end
130
+
131
+ # Shut down any pooled connections.
132
+ def close
133
+ while !@connections.empty?
134
+ begin
135
+ conn = @connections.pop(true)
136
+ conn.finish if conn.started?
137
+ rescue StandardError
138
+ # ignore
139
+ end
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def default_cache_dir
146
+ xdg = ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache")
147
+ File.join(xdg, "scint", "index", Cache.slug_for(@uri))
148
+ end
149
+
150
+ # Fetch a top-level endpoint (names or versions).
151
+ # Uses ETag for conditional requests and Range for incremental versions updates.
152
+ def fetch_endpoint(endpoint)
153
+ @mutex.synchronize do
154
+ return @fetched[endpoint] if @fetched.key?(endpoint)
155
+ end
156
+
157
+ if endpoint == "versions"
158
+ data = fetch_versions_with_range
159
+ else
160
+ etag = @cache.names_etag
161
+ response = http_get("#{@source_uri}/#{endpoint}", etag: etag)
162
+
163
+ case response
164
+ when Net::HTTPNotModified
165
+ data = @cache.names
166
+ when Net::HTTPSuccess
167
+ data = decode_body(response)
168
+ @cache.write_names(data, etag: extract_etag(response))
169
+ else
170
+ raise NetworkError, "Failed to fetch #{endpoint}: HTTP #{response.code}"
171
+ end
172
+ end
173
+
174
+ @mutex.synchronize { @fetched[endpoint] = data }
175
+ data
176
+ end
177
+
178
+ def fetch_versions_with_range
179
+ etag = @cache.versions_etag
180
+ local_size = @cache.versions_size
181
+
182
+ if local_size > 0
183
+ # Try range request (subtract 1 byte for overlap)
184
+ response = http_get("#{@source_uri}/versions", etag: etag, range_start: local_size - 1)
185
+
186
+ case response
187
+ when Net::HTTPNotModified
188
+ return @cache.versions
189
+ when Net::HTTPPartialContent
190
+ body = decode_body(response)
191
+ # Skip the overlapping byte
192
+ tail = body.byteslice(1..)
193
+ if tail && !tail.empty?
194
+ @cache.write_versions(tail, etag: extract_etag(response), append: true)
195
+ end
196
+ return @cache.versions
197
+ when Net::HTTPSuccess
198
+ # Server ignored range, gave us full response
199
+ data = decode_body(response)
200
+ @cache.write_versions(data, etag: extract_etag(response))
201
+ return data
202
+ when Net::HTTPRequestedRangeNotSatisfiable
203
+ # Fall through to full fetch
204
+ else
205
+ raise NetworkError, "Failed to fetch versions (range): HTTP #{response.code}"
206
+ end
207
+ end
208
+
209
+ # Full fetch
210
+ response = http_get("#{@source_uri}/versions", etag: etag)
211
+ case response
212
+ when Net::HTTPNotModified
213
+ @cache.versions
214
+ when Net::HTTPSuccess
215
+ data = decode_body(response)
216
+ @cache.write_versions(data, etag: extract_etag(response))
217
+ data
218
+ else
219
+ raise NetworkError, "Failed to fetch versions: HTTP #{response.code}"
220
+ end
221
+ end
222
+
223
+ # Fetch info endpoint for a single gem.
224
+ def fetch_info_endpoint(gem_name)
225
+ etag = @cache.info_etag(gem_name)
226
+ response = http_get("#{@source_uri}/info/#{gem_name}", etag: etag)
227
+
228
+ case response
229
+ when Net::HTTPNotModified
230
+ @cache.info(gem_name)
231
+ when Net::HTTPSuccess
232
+ data = decode_body(response)
233
+ @cache.write_info(gem_name, data, etag: extract_etag(response))
234
+ data
235
+ when Net::HTTPNotFound
236
+ nil
237
+ else
238
+ raise NetworkError, "Failed to fetch info/#{gem_name}: HTTP #{response.code}"
239
+ end
240
+ end
241
+
242
+ def http_get(url, etag: nil, range_start: nil)
243
+ uri = URI.parse(url)
244
+ conn = checkout_connection(uri)
245
+
246
+ begin
247
+ request = Net::HTTP::Get.new(uri.request_uri)
248
+ request["User-Agent"] = USER_AGENT
249
+ request["Accept-Encoding"] = ACCEPT_ENCODING
250
+ request["If-None-Match"] = %("#{etag}") if etag
251
+ request["Range"] = "bytes=#{range_start}-" if range_start
252
+ @credentials&.apply!(request, uri)
253
+
254
+ conn.request(request)
255
+ ensure
256
+ checkin_connection(conn)
257
+ end
258
+ end
259
+
260
+ def checkout_connection(uri)
261
+ begin
262
+ conn = @connections.pop(true)
263
+ return conn if conn.started?
264
+ rescue ThreadError
265
+ # Queue empty
266
+ end
267
+
268
+ conn = Net::HTTP.new(uri.host, uri.port)
269
+ conn.use_ssl = (uri.scheme == "https")
270
+ conn.open_timeout = DEFAULT_TIMEOUT
271
+ conn.read_timeout = DEFAULT_TIMEOUT
272
+ conn.start
273
+ conn
274
+ end
275
+
276
+ def checkin_connection(conn)
277
+ @connections.push(conn) if conn.started?
278
+ rescue StandardError
279
+ # ignore
280
+ end
281
+
282
+ def decode_body(response)
283
+ body = response.body
284
+ return body unless body
285
+
286
+ if response["Content-Encoding"] == "gzip"
287
+ Zlib::GzipReader.new(StringIO.new(body)).read
288
+ else
289
+ body
290
+ end
291
+ end
292
+
293
+ def extract_etag(response)
294
+ return nil unless response["ETag"]
295
+ etag = response["ETag"].delete_prefix("W/")
296
+ etag = etag.delete_prefix('"').delete_suffix('"')
297
+ etag.empty? ? nil : etag
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scint
4
+ module Index
5
+ class Parser
6
+ # Parse the compact index "names" endpoint response.
7
+ # Returns array of gem name strings.
8
+ def parse_names(data)
9
+ return [] if data.nil? || data.empty?
10
+ lines = strip_header(data)
11
+ lines
12
+ end
13
+
14
+ # Parse the compact index "versions" endpoint response.
15
+ # Returns a hash:
16
+ # { name => [[name, version, platform], ...], ... }
17
+ # Also stores checksums accessible via #info_checksums.
18
+ def parse_versions(data)
19
+ @versions_by_name = Hash.new { |h, k| h[k] = [] }
20
+ @info_checksums = {}
21
+
22
+ return @versions_by_name if data.nil? || data.empty?
23
+
24
+ strip_header(data).each do |line|
25
+ line.freeze
26
+
27
+ name_end = line.index(" ")
28
+ next unless name_end
29
+
30
+ versions_end = line.index(" ", name_end + 1)
31
+ name = line[0, name_end].freeze
32
+
33
+ if versions_end
34
+ versions_string = line[name_end + 1, versions_end - name_end - 1]
35
+ @info_checksums[name] = line[versions_end + 1, line.size - versions_end - 1]
36
+ else
37
+ versions_string = line[name_end + 1, line.size - name_end - 1]
38
+ @info_checksums[name] = ""
39
+ end
40
+
41
+ versions_string.split(",") do |version|
42
+ delete = version.delete_prefix!("-")
43
+ parts = version.split("-", 2)
44
+ entry = parts.unshift(name)
45
+ if delete
46
+ @versions_by_name[name].delete(entry)
47
+ else
48
+ @versions_by_name[name] << entry
49
+ end
50
+ end
51
+ end
52
+
53
+ @versions_by_name
54
+ end
55
+
56
+ # Returns checksums collected during parse_versions.
57
+ # Keys are gem names, values are checksum strings.
58
+ def info_checksums
59
+ @info_checksums || {}
60
+ end
61
+
62
+ # Parse a compact index "info/{gem_name}" endpoint response.
63
+ # Returns array of entries:
64
+ # [name, version, platform, deps_hash, requirements_hash]
65
+ #
66
+ # deps_hash: { "dep_name" => "version_constraints", ... }
67
+ # requirements_hash: { "ruby" => ">= 2.7", "rubygems" => ">= 3.0" }
68
+ def parse_info(name, data)
69
+ return [] if data.nil? || data.empty?
70
+
71
+ strip_header(data).map do |line|
72
+ parse_info_line(name, line)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # Strip the "---\n" header that compact index responses may include.
79
+ def strip_header(data)
80
+ lines = data.split("\n")
81
+ header = lines.index("---")
82
+ header ? lines[(header + 1)..] : lines
83
+ end
84
+
85
+ # Parse a single info line using the compact index format:
86
+ # VERSION[-PLATFORM] DEP1:REQ1&REQ2,DEP2:REQ3|RUBY_REQ:VAL1&VAL2,RUBYGEMS_REQ:VAL3
87
+ #
88
+ # - Space separates version from the rest
89
+ # - "|" separates dependency list from requirement list
90
+ # - "," separates individual deps or reqs
91
+ # - ":" separates name from version constraints within each dep/req
92
+ # - "&" separates multiple version constraints for one dep/req
93
+ def parse_info_line(name, line)
94
+ version_and_platform, rest = line.split(" ", 2)
95
+
96
+ # Split version and platform
97
+ version, platform = version_and_platform.split("-", 2)
98
+ platform ||= "ruby"
99
+
100
+ # Split rest into deps and requirements by "|"
101
+ deps = {}
102
+ reqs = {}
103
+
104
+ if rest && !rest.empty?
105
+ deps_str, reqs_str = rest.split("|", 2)
106
+
107
+ # Parse dependencies
108
+ if deps_str && !deps_str.empty?
109
+ deps_str.split(",").each do |dep_entry|
110
+ parts = dep_entry.split(":")
111
+ dep_name = parts[0]
112
+ next if dep_name.nil? || dep_name.empty?
113
+ dep_name = -dep_name # freeze and deduplicate
114
+ if parts.size > 1
115
+ deps[dep_name] = parts[1].split("&").join(", ")
116
+ else
117
+ deps[dep_name] = ">= 0"
118
+ end
119
+ end
120
+ end
121
+
122
+ # Parse requirements (ruby, rubygems version constraints)
123
+ if reqs_str && !reqs_str.empty?
124
+ reqs_str.split(",").each do |req_entry|
125
+ parts = req_entry.split(":")
126
+ req_name = parts[0]
127
+ next if req_name.nil? || req_name.empty?
128
+ req_name = -req_name.strip
129
+ if parts.size > 1
130
+ reqs[req_name] = parts[1].split("&").join(", ")
131
+ else
132
+ reqs[req_name] = ">= 0"
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ [name, version, platform, deps, reqs]
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../fs"
4
+ require_relative "../platform"
5
+ require_relative "../errors"
6
+ require "open3"
7
+
8
+ module Scint
9
+ module Installer
10
+ module ExtensionBuilder
11
+ module_function
12
+
13
+ # Build native extensions for a prepared gem.
14
+ # prepared_gem: PreparedGem struct
15
+ # bundle_path: .bundle/ root
16
+ # abi_key: e.g. "ruby-3.3.0-arm64-darwin24" (defaults to Platform.abi_key)
17
+ def build(prepared_gem, bundle_path, cache_layout, abi_key: Platform.abi_key, compile_slots: 1, output_tail: nil)
18
+ spec = prepared_gem.spec
19
+ ruby_dir = ruby_install_dir(bundle_path)
20
+ build_ruby_dir = cache_layout.install_ruby_dir
21
+
22
+ # Check global extension cache first
23
+ cached_ext = cache_layout.ext_path(spec, abi_key)
24
+ if Dir.exist?(cached_ext) && File.exist?(File.join(cached_ext, "gem.build_complete"))
25
+ link_extensions(cached_ext, ruby_dir, spec, abi_key)
26
+ return true
27
+ end
28
+
29
+ # Build in a temp dir, then cache
30
+ 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
+ FS.with_tempdir("scint-ext") do |tmpdir|
35
+ build_dir = File.join(tmpdir, "build")
36
+ install_dir = File.join(tmpdir, "install")
37
+ FS.mkdir_p(build_dir)
38
+ FS.mkdir_p(install_dir)
39
+
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)
42
+ end
43
+
44
+ # Write marker
45
+ File.write(File.join(install_dir, "gem.build_complete"), "")
46
+
47
+ # Cache globally
48
+ FS.mkdir_p(File.dirname(cached_ext))
49
+ FS.atomic_move(install_dir, cached_ext)
50
+ end
51
+
52
+ link_extensions(cached_ext, ruby_dir, spec, abi_key)
53
+ true
54
+ end
55
+
56
+ # True when a completed global extension build exists for this spec + ABI.
57
+ def cached_build_available?(spec, cache_layout, abi_key: Platform.abi_key)
58
+ cached_ext = cache_layout.ext_path(spec, abi_key)
59
+ Dir.exist?(cached_ext) && File.exist?(File.join(cached_ext, "gem.build_complete"))
60
+ end
61
+
62
+ # Link already-compiled extensions from global cache into bundle_path.
63
+ # Returns true when cache was present and linked, false otherwise.
64
+ def link_cached_build(prepared_gem, bundle_path, cache_layout, abi_key: Platform.abi_key)
65
+ spec = prepared_gem.spec
66
+ return false unless cached_build_available?(spec, cache_layout, abi_key: abi_key)
67
+
68
+ ruby_dir = ruby_install_dir(bundle_path)
69
+ cached_ext = cache_layout.ext_path(spec, abi_key)
70
+ link_extensions(cached_ext, ruby_dir, spec, abi_key)
71
+ true
72
+ end
73
+
74
+ # --- private ---
75
+
76
+ def buildable_source_dir?(gem_dir)
77
+ find_extension_dirs(gem_dir).any?
78
+ end
79
+
80
+ def find_extension_dirs(gem_dir)
81
+ dirs = []
82
+
83
+ # extconf.rb in ext/ subdirectories
84
+ Dir.glob(File.join(gem_dir, "ext", "**", "extconf.rb")).each do |path|
85
+ dirs << File.dirname(path)
86
+ end
87
+
88
+ # CMakeLists.txt in ext/
89
+ Dir.glob(File.join(gem_dir, "ext", "**", "CMakeLists.txt")).each do |path|
90
+ dir = File.dirname(path)
91
+ dirs << dir unless dirs.include?(dir)
92
+ end
93
+
94
+ # Rakefile-based (look for Rakefile in ext/)
95
+ if dirs.empty?
96
+ Dir.glob(File.join(gem_dir, "ext", "**", "Rakefile")).each do |path|
97
+ dirs << File.dirname(path)
98
+ end
99
+ end
100
+
101
+ dirs.uniq
102
+ end
103
+
104
+ def compile_extension(ext_dir, build_dir, install_dir, gem_dir, spec, build_ruby_dir, compile_slots, output_tail = nil)
105
+ make_jobs = adaptive_make_jobs(compile_slots)
106
+ env = build_env(gem_dir, build_ruby_dir, make_jobs)
107
+
108
+ if File.exist?(File.join(ext_dir, "extconf.rb"))
109
+ compile_extconf(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
110
+ elsif File.exist?(File.join(ext_dir, "CMakeLists.txt"))
111
+ compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
112
+ elsif File.exist?(File.join(ext_dir, "Rakefile"))
113
+ compile_rake(ext_dir, build_dir, install_dir, build_ruby_dir, env, output_tail)
114
+ else
115
+ raise ExtensionBuildError, "No known build system in #{ext_dir}"
116
+ end
117
+ end
118
+
119
+ def compile_extconf(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
120
+ run_cmd(env, RbConfig.ruby, File.join(ext_dir, "extconf.rb"),
121
+ "--with-opt-dir=#{RbConfig::CONFIG["prefix"]}",
122
+ chdir: build_dir, output_tail: output_tail)
123
+ run_cmd(env, "make", "-j#{make_jobs}", "-C", build_dir, output_tail: output_tail)
124
+ run_cmd(env, "make", "install", "DESTDIR=", "sitearchdir=#{install_dir}", "sitelibdir=#{install_dir}",
125
+ chdir: build_dir, output_tail: output_tail)
126
+ end
127
+
128
+ def compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
129
+ run_cmd(env, "cmake", ext_dir, "-B", build_dir,
130
+ "-DCMAKE_INSTALL_PREFIX=#{install_dir}", output_tail: output_tail)
131
+ run_cmd(env, "cmake", "--build", build_dir, "--parallel", make_jobs.to_s, output_tail: output_tail)
132
+ run_cmd(env, "cmake", "--install", build_dir, output_tail: output_tail)
133
+ end
134
+
135
+ def compile_rake(ext_dir, build_dir, install_dir, ruby_dir, env, output_tail = nil)
136
+ rake_exe = find_rake_executable(ruby_dir)
137
+ begin
138
+ if rake_exe
139
+ run_cmd(env, RbConfig.ruby, rake_exe, "compile",
140
+ chdir: ext_dir, output_tail: output_tail)
141
+ else
142
+ run_cmd(env, RbConfig.ruby, "-S", "rake", "compile",
143
+ chdir: ext_dir, output_tail: output_tail)
144
+ end
145
+ rescue ExtensionBuildError => e
146
+ # Some gems ship a Rakefile but do not expose a compile task.
147
+ # Treat this as "nothing to build" rather than a hard failure.
148
+ raise unless e.message.include?("Don't know how to build task 'compile'")
149
+ return
150
+ end
151
+ # Copy built artifacts to install_dir
152
+ Dir.glob(File.join(ext_dir, "**", "*.{so,bundle,dll,dylib}")).each do |so|
153
+ FileUtils.cp(so, install_dir)
154
+ end
155
+ end
156
+
157
+ def find_rake_executable(ruby_dir)
158
+ gems_dir = File.join(ruby_dir, "gems")
159
+ return nil unless Dir.exist?(gems_dir)
160
+
161
+ # Prefer highest installed rake version.
162
+ rake_dirs = Dir.glob(File.join(gems_dir, "rake-*")).sort.reverse
163
+ rake_dirs.each do |dir|
164
+ %w[exe bin].each do |subdir|
165
+ path = File.join(dir, subdir, "rake")
166
+ return path if File.file?(path)
167
+ end
168
+ end
169
+ nil
170
+ end
171
+
172
+ def link_extensions(cached_ext, ruby_dir, spec, abi_key)
173
+ ext_install_dir = File.join(ruby_dir, "extensions",
174
+ Platform.gem_arch, Platform.extension_api_version,
175
+ spec_full_name(spec))
176
+ return if Dir.exist?(ext_install_dir)
177
+
178
+ FS.hardlink_tree(cached_ext, ext_install_dir)
179
+ end
180
+
181
+ def build_env(gem_dir, build_ruby_dir, make_jobs)
182
+ ruby_bin = File.join(build_ruby_dir, "bin")
183
+ path = [ruby_bin, ENV["PATH"]].compact.reject(&:empty?).join(File::PATH_SEPARATOR)
184
+ {
185
+ "GEM_HOME" => build_ruby_dir,
186
+ "GEM_PATH" => build_ruby_dir,
187
+ "BUNDLE_PATH" => build_ruby_dir,
188
+ "BUNDLE_GEMFILE" => "",
189
+ "MAKEFLAGS" => "-j#{make_jobs}",
190
+ "PATH" => path,
191
+ "CFLAGS" => "-I#{RbConfig::CONFIG["rubyhdrdir"]} -I#{RbConfig::CONFIG["rubyarchhdrdir"]}",
192
+ }
193
+ end
194
+
195
+ def adaptive_make_jobs(compile_slots)
196
+ slots = [compile_slots.to_i, 1].max
197
+ jobs = Platform.cpu_count / slots
198
+ [jobs, 1].max
199
+ end
200
+
201
+ def run_cmd(env, *cmd, chdir: nil, output_tail: nil)
202
+ opts = { chdir: chdir }.compact
203
+
204
+ if ENV["SCINT_DEBUG"]
205
+ pid = Process.spawn(env, *cmd, **opts)
206
+ _, status = Process.wait2(pid)
207
+ unless status.success?
208
+ raise ExtensionBuildError,
209
+ "Command failed (exit #{status.exitstatus}): #{cmd.join(" ")}"
210
+ end
211
+ return
212
+ end
213
+
214
+ # Stream output line-by-line so the UX gets live compile progress
215
+ # instead of waiting for the entire subprocess to finish.
216
+ all_output = +""
217
+ tail_lines = []
218
+ cmd_label = "$ #{cmd.join(" ")}"
219
+
220
+ Open3.popen2e(env, *cmd, **opts) do |stdin, out_err, wait_thr|
221
+ stdin.close
222
+
223
+ out_err.each_line do |line|
224
+ stripped = line.rstrip
225
+ all_output << line
226
+ next if stripped.empty?
227
+
228
+ tail_lines << stripped
229
+ tail_lines.shift if tail_lines.length > 5
230
+
231
+ if output_tail
232
+ output_tail.call([cmd_label, *tail_lines])
233
+ end
234
+ end
235
+
236
+ status = wait_thr.value
237
+ unless status.success?
238
+ details = all_output.strip
239
+ message = "Command failed (exit #{status.exitstatus}): #{cmd.join(" ")}"
240
+ message = "#{message}\n#{details}" unless details.empty?
241
+ raise ExtensionBuildError, message
242
+ end
243
+ end
244
+ end
245
+
246
+ def spec_full_name(spec)
247
+ name = spec.name
248
+ version = spec.version
249
+ plat = spec.respond_to?(:platform) ? spec.platform : nil
250
+ base = "#{name}-#{version}"
251
+ (plat.nil? || plat.to_s == "ruby" || plat.to_s.empty?) ? base : "#{base}-#{plat}"
252
+ end
253
+
254
+ def ruby_install_dir(bundle_path)
255
+ File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
256
+ end
257
+
258
+ private_class_method :find_extension_dirs, :compile_extension,
259
+ :compile_extconf, :compile_cmake, :compile_rake,
260
+ :find_rake_executable, :link_extensions, :build_env, :run_cmd,
261
+ :spec_full_name, :ruby_install_dir
262
+ end
263
+ end
264
+ end