scint 0.1.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.
@@ -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,12 +77,47 @@ 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
72
84
  end
73
85
 
86
+ # True when a gem has native extension sources that need compiling.
87
+ # Platform-specific gems usually ship precompiled binaries and should
88
+ # not be compiled from ext/ unless they lack support for this Ruby.
89
+ def needs_build?(spec, gem_dir)
90
+ platform = spec.respond_to?(:platform) ? spec.platform : nil
91
+ if platform && !platform.to_s.empty? && platform.to_s != "ruby"
92
+ return prebuilt_missing_for_ruby?(gem_dir) && buildable_source_dir?(gem_dir)
93
+ end
94
+
95
+ buildable_source_dir?(gem_dir)
96
+ end
97
+
98
+ # Detect versioned prebuilt extension folders like:
99
+ # lib/sqlite3/3.1, lib/sqlite3/3.2 ...
100
+ # If present, the current Ruby minor must exist or we need a build.
101
+ def prebuilt_missing_for_ruby?(gem_dir)
102
+ ruby_minor = RUBY_VERSION[/\d+\.\d+/]
103
+ lib_dir = File.join(gem_dir, "lib")
104
+ return false unless Dir.exist?(lib_dir)
105
+
106
+ Dir.children(lib_dir).each do |child|
107
+ child_path = File.join(lib_dir, child)
108
+ next unless File.directory?(child_path)
109
+
110
+ version_dirs = Dir.children(child_path).select do |entry|
111
+ File.directory?(File.join(child_path, entry)) && entry.match?(/\A\d+\.\d+\z/)
112
+ end
113
+ next if version_dirs.empty?
114
+
115
+ return true unless version_dirs.include?(ruby_minor)
116
+ end
117
+
118
+ false
119
+ end
120
+
74
121
  # --- private ---
75
122
 
76
123
  def buildable_source_dir?(gem_dir)
@@ -85,9 +132,18 @@ module Scint
85
132
  dirs << File.dirname(path)
86
133
  end
87
134
 
88
- # CMakeLists.txt in ext/
89
- Dir.glob(File.join(gem_dir, "ext", "**", "CMakeLists.txt")).each do |path|
90
- 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|
91
147
  dirs << dir unless dirs.include?(dir)
92
148
  end
93
149
 
@@ -106,7 +162,7 @@ module Scint
106
162
  env = build_env(gem_dir, build_ruby_dir, make_jobs)
107
163
 
108
164
  if File.exist?(File.join(ext_dir, "extconf.rb"))
109
- 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)
110
166
  elsif File.exist?(File.join(ext_dir, "CMakeLists.txt"))
111
167
  compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
112
168
  elsif File.exist?(File.join(ext_dir, "Rakefile"))
@@ -116,13 +172,18 @@ module Scint
116
172
  end
117
173
  end
118
174
 
119
- 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
120
181
  run_cmd(env, RbConfig.ruby, File.join(ext_dir, "extconf.rb"),
121
182
  "--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)
183
+ chdir: ext_dir, output_tail: output_tail)
184
+ run_cmd(env, "make", "-j#{make_jobs}", "-C", ext_dir, output_tail: output_tail)
124
185
  run_cmd(env, "make", "install", "DESTDIR=", "sitearchdir=#{install_dir}", "sitelibdir=#{install_dir}",
125
- chdir: build_dir, output_tail: output_tail)
186
+ chdir: ext_dir, output_tail: output_tail)
126
187
  end
127
188
 
128
189
  def compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
@@ -173,9 +234,21 @@ module Scint
173
234
  ext_install_dir = File.join(ruby_dir, "extensions",
174
235
  Platform.gem_arch, Platform.extension_api_version,
175
236
  spec_full_name(spec))
176
- 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
240
+
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)
177
245
 
178
- FS.hardlink_tree(cached_ext, ext_install_dir)
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
179
252
  end
180
253
 
181
254
  def build_env(gem_dir, build_ruby_dir, make_jobs)
@@ -244,21 +317,13 @@ module Scint
244
317
  end
245
318
 
246
319
  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")
320
+ SpecUtils.full_name(spec)
256
321
  end
257
322
 
258
323
  private_class_method :find_extension_dirs, :compile_extension,
259
324
  :compile_extconf, :compile_cmake, :compile_rake,
260
- :find_rake_executable, :link_extensions, :build_env, :run_cmd,
261
- :spec_full_name, :ruby_install_dir
325
+ :find_rake_executable, :link_extensions, :sync_extension_artifacts_into_gem,
326
+ :build_env, :run_cmd, :prebuilt_missing_for_ruby?
262
327
  end
263
328
  end
264
329
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../fs"
4
4
  require_relative "../platform"
5
+ require_relative "../spec_utils"
5
6
  require "pathname"
6
7
 
7
8
  module Scint
@@ -20,7 +21,7 @@ module Scint
20
21
  # Link gem files + gemspec only (no binstubs).
21
22
  # This allows binstubs to be scheduled as a separate DAG task.
22
23
  def link_files(prepared_gem, bundle_path)
23
- ruby_dir = ruby_install_dir(bundle_path)
24
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
24
25
  link_files_to_ruby_dir(prepared_gem, ruby_dir)
25
26
  end
26
27
 
@@ -28,12 +29,12 @@ module Scint
28
29
  # This is used for the install-time hermetic build environment.
29
30
  def link_files_to_ruby_dir(prepared_gem, ruby_dir)
30
31
  spec = prepared_gem.spec
31
- full_name = spec_full_name(spec)
32
+ full_name = SpecUtils.full_name(spec)
32
33
 
33
34
  # 1. Link gem files into gems/{full_name}/
34
35
  gem_dest = File.join(ruby_dir, "gems", full_name)
35
36
  unless Dir.exist?(gem_dest)
36
- FS.hardlink_tree(prepared_gem.extracted_path, gem_dest)
37
+ FS.clone_tree(prepared_gem.extracted_path, gem_dest)
37
38
  end
38
39
 
39
40
  # 2. Write gemspec into specifications/
@@ -42,9 +43,9 @@ module Scint
42
43
 
43
44
  # Write binstubs for one already-linked gem.
44
45
  def write_binstubs(prepared_gem, bundle_path)
45
- ruby_dir = ruby_install_dir(bundle_path)
46
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
46
47
  spec = prepared_gem.spec
47
- full_name = spec_full_name(spec)
48
+ full_name = SpecUtils.full_name(spec)
48
49
  gem_dest = File.join(ruby_dir, "gems", full_name)
49
50
  return unless Dir.exist?(gem_dest)
50
51
 
@@ -101,7 +102,7 @@ module Scint
101
102
  #!/usr/bin/env ruby
102
103
  # frozen_string_literal: true
103
104
  #
104
- # This file was generated by scint for #{spec_full_name(spec)}
105
+ # This file was generated by scint for #{SpecUtils.full_name(spec)}
105
106
  #
106
107
  gem "#{spec.name}", "#{spec.version}"
107
108
  load Gem.bin_path("#{spec.name}", "#{exe_name}", "#{spec.version}")
@@ -193,34 +194,17 @@ module Scint
193
194
  Gem::Specification.new do |s|
194
195
  s.name = #{spec.name.inspect}
195
196
  s.version = #{spec.version.to_s.inspect}
196
- s.platform = #{platform_str(spec).inspect}
197
+ s.platform = #{SpecUtils.platform_str(spec).inspect}
197
198
  s.authors = ["scint"]
198
199
  s.summary = "Installed by scint"
199
200
  end
200
201
  RUBY
201
202
  end
202
203
 
203
- def spec_full_name(spec)
204
- name = spec.name
205
- version = spec.version
206
- plat = platform_str(spec)
207
- base = "#{name}-#{version}"
208
- (plat == "ruby" || plat.empty?) ? base : "#{base}-#{plat}"
209
- end
210
-
211
- def platform_str(spec)
212
- p = spec.respond_to?(:platform) ? spec.platform : nil
213
- p.nil? ? "ruby" : p.to_s
214
- end
215
-
216
- def ruby_install_dir(bundle_path)
217
- File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
218
- end
219
-
220
204
  private_class_method :write_gemspec, :write_binstubs_impl, :write_ruby_bin_stub,
221
205
  :write_bundle_bin_wrapper, :extract_executables,
222
206
  :detect_executables_from_files, :augment_executable_metadata, :infer_bindir,
223
- :minimal_gemspec, :spec_full_name, :platform_str, :ruby_install_dir
207
+ :minimal_gemspec
224
208
  end
225
209
  end
226
210
  end
@@ -7,6 +7,7 @@ module Scint
7
7
  module Installer
8
8
  module Planner
9
9
  module_function
10
+ PATH_GLOB_DEFAULT = "{,*,*/*}.gemspec"
10
11
 
11
12
  # Compare resolved specs against what's already installed.
12
13
  # Returns an Array of PlanEntry with action set to one of:
@@ -18,16 +19,17 @@ module Scint
18
19
  # Download entries are sorted largest-first so big gems start early,
19
20
  # keeping the pipeline saturated while small gems fill in gaps.
20
21
  def plan(resolved_specs, bundle_path, cache_layout)
21
- ruby_dir = ruby_install_dir(bundle_path)
22
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
22
23
  entries = resolved_specs.map do |spec|
23
24
  plan_one(spec, ruby_dir, cache_layout)
24
25
  end
25
26
 
26
- # Stable partition: downloads first (bigsmall), then everything else
27
- downloads, rest = entries.partition { |e| e.action == :download }
27
+ # Keep built-ins first, then downloads (big->small), then the rest.
28
+ builtins, non_builtins = entries.partition { |e| e.action == :builtin }
29
+ downloads, rest = non_builtins.partition { |e| e.action == :download }
28
30
  downloads.sort_by! { |e| -(estimated_size(e.spec)) }
29
31
 
30
- downloads + rest
32
+ builtins + downloads + rest
31
33
  end
32
34
 
33
35
  def plan_one(spec, ruby_dir, cache_layout)
@@ -58,7 +60,7 @@ module Scint
58
60
  # Local path sources are linked directly from their source tree.
59
61
  local_source = local_source_path(spec)
60
62
  if local_source
61
- action = ExtensionBuilder.buildable_source_dir?(local_source) ? :build_ext : :link
63
+ action = ExtensionBuilder.needs_build?(spec, local_source) ? :build_ext : :link
62
64
  return PlanEntry.new(spec: spec, action: action, cached_path: local_source, gem_path: gem_path)
63
65
  end
64
66
 
@@ -75,7 +77,7 @@ module Scint
75
77
 
76
78
  def needs_ext_build?(spec, cache_layout)
77
79
  extracted = cache_layout.extracted_path(spec)
78
- return false unless ExtensionBuilder.buildable_source_dir?(extracted)
80
+ return false unless ExtensionBuilder.needs_build?(spec, extracted)
79
81
 
80
82
  !ExtensionBuilder.cached_build_available?(spec, cache_layout)
81
83
  end
@@ -83,7 +85,7 @@ module Scint
83
85
  def extension_link_missing?(spec, ruby_dir, cache_layout)
84
86
  extracted = cache_layout.extracted_path(spec)
85
87
  return false unless Dir.exist?(extracted)
86
- return false unless ExtensionBuilder.buildable_source_dir?(extracted)
88
+ return false unless ExtensionBuilder.needs_build?(spec, extracted)
87
89
 
88
90
  full = cache_layout.full_name(spec)
89
91
  ext_install_dir = File.join(
@@ -97,10 +99,6 @@ module Scint
97
99
  !Dir.exist?(ext_install_dir)
98
100
  end
99
101
 
100
- def ruby_install_dir(bundle_path)
101
- File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
102
- end
103
-
104
102
  # Rough size estimate for download ordering.
105
103
  # If we don't know, use 0 so unknowns sort after large known gems.
106
104
  def estimated_size(spec)
@@ -130,11 +128,37 @@ module Scint
130
128
  return nil if source_str.end_with?(".git") || source_str.include?(".git/")
131
129
 
132
130
  absolute = File.expand_path(source_str, Dir.pwd)
133
- Dir.exist?(absolute) ? absolute : nil
131
+ return nil unless Dir.exist?(absolute)
132
+
133
+ spec_name =
134
+ if spec.respond_to?(:name)
135
+ spec.name.to_s
136
+ else
137
+ spec[:name].to_s
138
+ end
139
+ return absolute if spec_name.empty?
140
+ return absolute if File.exist?(File.join(absolute, "#{spec_name}.gemspec"))
141
+
142
+ glob =
143
+ if source.respond_to?(:glob) && !source.glob.to_s.empty?
144
+ source.glob.to_s
145
+ else
146
+ PATH_GLOB_DEFAULT
147
+ end
148
+
149
+ Dir.glob(File.join(absolute, glob)).each do |path|
150
+ return File.dirname(path) if File.basename(path, ".gemspec") == spec_name
151
+ end
152
+
153
+ Dir.glob(File.join(absolute, "**", "*.gemspec")).each do |path|
154
+ return File.dirname(path) if File.basename(path, ".gemspec") == spec_name
155
+ end
156
+
157
+ absolute
134
158
  end
135
159
 
136
160
  private_class_method :plan_one, :needs_ext_build?, :extension_link_missing?,
137
- :ruby_install_dir, :estimated_size, :local_source_path
161
+ :estimated_size, :local_source_path
138
162
  end
139
163
  end
140
164
  end
@@ -6,6 +6,7 @@ require_relative "../gem/extractor"
6
6
  require_relative "../cache/layout"
7
7
  require_relative "../fs"
8
8
  require_relative "../errors"
9
+ require_relative "../spec_utils"
9
10
 
10
11
  module Scint
11
12
  module Installer
@@ -182,15 +183,7 @@ module Scint
182
183
 
183
184
  def gem_download_uri(entry)
184
185
  spec = entry.spec
185
- name = spec.respond_to?(:name) ? spec.name : spec[:name]
186
- version = spec.respond_to?(:version) ? spec.version : spec[:version]
187
- platform = spec.respond_to?(:platform) ? spec.platform : spec[:platform]
188
-
189
- filename = if platform && platform.to_s != "ruby" && platform.to_s != ""
190
- "#{name}-#{version}-#{platform}.gem"
191
- else
192
- "#{name}-#{version}.gem"
193
- end
186
+ filename = "#{SpecUtils.full_name(spec)}.gem"
194
187
 
195
188
  # Use cached_path if provided, otherwise construct from source
196
189
  if entry.cached_path
@@ -3,6 +3,7 @@
3
3
  require_relative "../source/rubygems"
4
4
  require_relative "../source/git"
5
5
  require_relative "../source/path"
6
+ require_relative "../spec_utils"
6
7
 
7
8
  module Scint
8
9
  module Lockfile
@@ -209,7 +210,7 @@ module Scint
209
210
  platform = $4 || "ruby"
210
211
  checksums_str = $6
211
212
 
212
- key = platform == "ruby" ? "#{name}-#{version}" : "#{name}-#{version}-#{platform}"
213
+ key = SpecUtils.full_name_for(name, version, platform)
213
214
 
214
215
  if checksums_str
215
216
  @checksums[key] = checksums_str.split(",").map(&:strip)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../spec_utils"
4
+
3
5
  module Scint
4
6
  module Lockfile
5
7
  # Writes a standard Gemfile.lock file from structured data.
@@ -33,67 +35,95 @@ module Scint
33
35
 
34
36
  def add_sources(out)
35
37
  # Group specs by source, preserving source order.
36
- # Specs store source as a URI string; sources are Source objects.
37
- # Match by checking if the spec's source URI matches any remote.
38
38
  specs_by_source = {}
39
39
  @data.sources.each { |s| specs_by_source[s] = [] }
40
40
 
41
41
  @data.specs.each do |spec|
42
- spec_src = spec.is_a?(Hash) ? spec[:source] : spec.source
43
- spec_uri = normalize_source_uri(spec_src)
44
-
45
- matched = @data.sources.find do |source|
46
- if source.respond_to?(:remotes)
47
- source.remotes.any? { |r| normalize_source_uri(r) == spec_uri }
48
- elsif source.respond_to?(:uri)
49
- normalize_source_uri(source.uri) == spec_uri
50
- else
51
- source == spec_src
52
- end
53
- end
42
+ target = match_source_for_spec(spec) || @data.sources.first
43
+ next unless target
54
44
 
55
- target = matched || @data.sources.first
56
45
  specs_by_source[target] ||= []
57
46
  specs_by_source[target] << spec
58
47
  end
59
48
 
60
- first = true
49
+ emitted = false
61
50
  @data.sources.each do |source|
62
- out << "\n" unless first
63
- first = false
51
+ source_specs = specs_by_source[source] || []
52
+ next if source_specs.empty?
53
+
54
+ out << "\n" if emitted
55
+ emitted = true
64
56
 
65
57
  out << source.to_lock
66
- add_specs(out, specs_by_source[source] || [])
58
+ add_specs(out, source_specs)
59
+ end
60
+ end
61
+
62
+ def match_source_for_spec(spec)
63
+ spec_source = spec.is_a?(Hash) ? spec[:source] : spec.source
64
+ return nil unless spec_source
65
+
66
+ @data.sources.find { |source| source_matches?(source, spec_source) }
67
+ end
68
+
69
+ def source_matches?(source, spec_source)
70
+ return true if source.equal?(spec_source)
71
+ return true if source == spec_source
72
+
73
+ spec_key = normalize_source_key(spec_source)
74
+ return false unless spec_key
75
+
76
+ if source.respond_to?(:remotes)
77
+ source.remotes.any? { |remote| normalize_source_key(remote) == spec_key }
78
+ elsif source.respond_to?(:uri)
79
+ normalize_source_key(source.uri) == spec_key
80
+ else
81
+ normalize_source_key(source) == spec_key
67
82
  end
68
83
  end
69
84
 
70
- def normalize_source_uri(uri)
71
- s = uri.to_s.chomp("/")
72
- s.sub(%r{^https?://}, "").downcase
85
+ def normalize_source_key(source_ref)
86
+ raw =
87
+ if source_ref.respond_to?(:uri)
88
+ source_ref.uri.to_s
89
+ elsif source_ref.respond_to?(:path)
90
+ source_ref.path.to_s
91
+ else
92
+ source_ref.to_s
93
+ end
94
+ return nil if raw.empty?
95
+
96
+ if raw.match?(%r{\Ahttps?://}i)
97
+ raw = raw.sub(%r{\Ahttps?://}i, "")
98
+ raw = raw.sub(%r{\.git/?\z}i, "")
99
+ raw.chomp("/").downcase
100
+ elsif raw.start_with?("/") || raw.start_with?(".")
101
+ File.expand_path(raw)
102
+ else
103
+ raw.sub(%r{\.git/?\z}i, "").chomp("/").downcase
104
+ end
73
105
  end
74
106
 
75
107
  def add_specs(out, specs)
76
108
  # Sort by full name (name-version-platform) for consistency
77
109
  sorted = specs.sort_by do |s|
78
110
  if s.is_a?(Hash)
79
- n = s[:name]
80
- v = s[:version]
81
- p = s[:platform]
82
- p == "ruby" ? "#{n}-#{v}" : "#{n}-#{v}-#{p}"
111
+ SpecUtils.full_name_for(s[:name], s[:version], s[:platform])
83
112
  else
84
- "#{s.name}-#{s.version}#{"-#{s.platform}" if s.platform != "ruby"}"
113
+ SpecUtils.full_name_for(s.name, s.version, s.platform)
85
114
  end
86
115
  end
87
116
 
88
117
  sorted.each do |spec|
89
- name, version, platform, deps = if spec.is_a?(Hash)
90
- [spec[:name], spec[:version], spec[:platform], spec[:dependencies] || []]
118
+ name, version, deps = if spec.is_a?(Hash)
119
+ [spec[:name], spec[:version], spec[:dependencies] || []]
91
120
  else
92
- [spec.name, spec.version, spec.platform, spec.dependencies || []]
121
+ [spec.name, spec.version, spec.dependencies || []]
93
122
  end
94
123
 
95
124
  # Format: " name (version)" or " name (version-platform)"
96
- version_str = platform && platform != "ruby" ? "#{version}-#{platform}" : version.to_s
125
+ platform_str = SpecUtils.platform_str(spec)
126
+ version_str = platform_str == "ruby" ? version.to_s : "#{version}-#{platform_str}"
97
127
  out << " #{name} (#{version_str})\n"
98
128
 
99
129
  # Dependencies of this spec (6-space indent)
@@ -153,19 +183,31 @@ module Scint
153
183
  return unless @data.checksums
154
184
  out << "\nCHECKSUMS\n"
155
185
 
156
- @data.checksums.sort.each do |key, values|
186
+ @data.checksums.each do |key, values|
187
+ rendered_key = format_checksum_key(key)
157
188
  if values && !values.empty?
158
- out << " #{key} #{values.join(",")}\n"
189
+ out << " #{rendered_key} #{values.join(",")}\n"
159
190
  else
160
- out << " #{key}\n"
191
+ out << " #{rendered_key}\n"
161
192
  end
162
193
  end
163
194
  end
164
195
 
196
+ def format_checksum_key(key)
197
+ match = key.to_s.match(/\A(.+)-(\d[^-]*)(?:-(.+))?\z/)
198
+ return key unless match
199
+
200
+ name = match[1]
201
+ version = match[2]
202
+ platform = match[3]
203
+ version_str = platform ? "#{version}-#{platform}" : version
204
+ "#{name} (#{version_str})"
205
+ end
206
+
165
207
  def add_ruby_version(out)
166
208
  return unless @data.ruby_version
167
209
  out << "\nRUBY VERSION\n"
168
- out << " #{@data.ruby_version}\n"
210
+ out << " #{@data.ruby_version}\n"
169
211
  end
170
212
 
171
213
  def add_bundled_with(out)
@@ -173,6 +215,7 @@ module Scint
173
215
  out << "\nBUNDLED WITH\n"
174
216
  out << " #{@data.bundler_version}\n"
175
217
  end
218
+
176
219
  end
177
220
  end
178
221
  end
@@ -40,6 +40,14 @@ module Scint
40
40
  RUBY_VERSION
41
41
  end
42
42
 
43
+ def ruby_minor_version_dir
44
+ @ruby_minor_version_dir ||= RUBY_VERSION.split(".")[0, 2].join(".") + ".0"
45
+ end
46
+
47
+ def ruby_install_dir(base)
48
+ File.join(base, "ruby", ruby_minor_version_dir)
49
+ end
50
+
43
51
  def extension_api_version
44
52
  ::Gem.extension_api_version
45
53
  end