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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "fileutils"
6
+ require_relative "../fs"
7
+ require_relative "../errors"
8
+
9
+ module Scint
10
+ module Downloader
11
+ class Fetcher
12
+ MAX_REDIRECTS = 5
13
+
14
+ def initialize(credentials: nil)
15
+ @connections = {} # "host:port" => Net::HTTP
16
+ @mutex = Thread::Mutex.new
17
+ @credentials = credentials
18
+ end
19
+
20
+ # Download a single file from uri to dest_path.
21
+ # Streams to a temp file then atomically renames.
22
+ # Returns { path: dest_path, size: bytes }
23
+ def fetch(uri, dest_path, checksum: nil)
24
+ uri = URI.parse(uri) unless uri.is_a?(URI)
25
+ FS.mkdir_p(File.dirname(dest_path))
26
+
27
+ tmp_path = "#{dest_path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
28
+ size = 0
29
+
30
+ begin
31
+ redirect_count = 0
32
+ current_uri = uri
33
+
34
+ loop do
35
+ http = connection_for(current_uri)
36
+ request = Net::HTTP::Get.new(current_uri.request_uri)
37
+ request["Accept-Encoding"] = "identity"
38
+ @credentials&.apply!(request, current_uri)
39
+
40
+ response = http.request(request)
41
+
42
+ case response
43
+ when Net::HTTPSuccess
44
+ File.open(tmp_path, "wb") do |f|
45
+ body = response.body
46
+ f.write(body)
47
+ size = body.bytesize
48
+ end
49
+ break
50
+ when Net::HTTPRedirection
51
+ redirect_count += 1
52
+ raise NetworkError, "Too many redirects for #{uri}" if redirect_count > MAX_REDIRECTS
53
+ location = response["location"]
54
+ current_uri = URI.parse(location)
55
+ next
56
+ else
57
+ raise NetworkError, "HTTP #{response.code} for #{uri}: #{response.message}"
58
+ end
59
+ end
60
+
61
+ if checksum
62
+ actual = Digest::SHA256.file(tmp_path).hexdigest
63
+ unless actual == checksum
64
+ File.delete(tmp_path) if File.exist?(tmp_path)
65
+ raise NetworkError, "Checksum mismatch for #{uri}: expected #{checksum}, got #{actual}"
66
+ end
67
+ end
68
+
69
+ File.rename(tmp_path, dest_path)
70
+ { path: dest_path, size: size }
71
+ rescue StandardError
72
+ File.delete(tmp_path) if File.exist?(tmp_path)
73
+ raise
74
+ end
75
+ end
76
+
77
+ # Close all persistent connections.
78
+ def close
79
+ @mutex.synchronize do
80
+ @connections.each_value do |http|
81
+ http.finish if http.started?
82
+ rescue StandardError
83
+ # ignore close errors
84
+ end
85
+ @connections.clear
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def connection_for(uri)
92
+ key = "#{uri.host}:#{uri.port}:#{uri.scheme}"
93
+
94
+ @mutex.synchronize do
95
+ http = @connections[key]
96
+ if http && http.started?
97
+ return http
98
+ end
99
+
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = (uri.scheme == "https")
102
+ http.open_timeout = 10
103
+ http.read_timeout = 30
104
+ http.keep_alive_timeout = 30
105
+ http.start
106
+
107
+ @connections[key] = http
108
+ http
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fetcher"
4
+ require_relative "../worker_pool"
5
+ require_relative "../platform"
6
+ require_relative "../errors"
7
+
8
+ module Scint
9
+ module Downloader
10
+ class Pool
11
+ MAX_RETRIES = 3
12
+ BACKOFF_BASE = 0.5 # seconds
13
+
14
+ attr_reader :size
15
+
16
+ def initialize(size: nil, on_progress: nil, credentials: nil)
17
+ @size = size || [Platform.cpu_count * 2, 50].min
18
+ @on_progress = on_progress
19
+ @credentials = credentials
20
+ @fetchers = {} # thread_id => Fetcher
21
+ @fetcher_mutex = Thread::Mutex.new
22
+ end
23
+
24
+ # Download a single URI to dest_path with retry logic.
25
+ # Returns { path:, size: }
26
+ 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
40
+ end
41
+ raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
42
+ end
43
+ end
44
+
45
+ # Download multiple items concurrently.
46
+ # items: [{ uri:, dest:, spec:, checksum: }]
47
+ # Returns array of { spec:, path:, size:, error: }
48
+ def download_batch(items)
49
+ results = []
50
+ result_mutex = Thread::Mutex.new
51
+
52
+ pool = WorkerPool.new(@size, name: "download")
53
+ remaining = items.size
54
+
55
+ done = Thread::Queue.new
56
+
57
+ pool.start do |item|
58
+ result = download_one(item)
59
+ result_mutex.synchronize { results << result }
60
+ @on_progress&.call(result)
61
+ done.push(true)
62
+ result
63
+ end
64
+
65
+ items.each { |item| pool.enqueue(item) }
66
+
67
+ remaining.times { done.pop }
68
+ pool.stop
69
+
70
+ results
71
+ end
72
+
73
+ # Close all connections across all threads.
74
+ def close
75
+ @fetcher_mutex.synchronize do
76
+ @fetchers.each_value(&:close)
77
+ @fetchers.clear
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def download_one(item)
84
+ uri = item[:uri]
85
+ dest = item[:dest]
86
+ spec = item[:spec]
87
+ checksum = item[:checksum]
88
+
89
+ result = download(uri, dest, checksum: checksum)
90
+ { spec: spec, path: result[:path], size: result[:size], error: nil }
91
+ rescue StandardError => e
92
+ { spec: spec, path: nil, size: 0, error: e }
93
+ end
94
+
95
+ # Each thread gets its own Fetcher for connection reuse without locking.
96
+ def thread_fetcher
97
+ tid = Thread.current.object_id
98
+ @fetcher_mutex.synchronize do
99
+ @fetchers[tid] ||= Fetcher.new(credentials: @credentials)
100
+ end
101
+ end
102
+
103
+ def reset_thread_fetcher
104
+ tid = Thread.current.object_id
105
+ @fetcher_mutex.synchronize do
106
+ old = @fetchers.delete(tid)
107
+ old&.close
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scint
4
+ class BundlerError < StandardError
5
+ def status_code
6
+ 1
7
+ end
8
+ end
9
+
10
+ class GemfileError < BundlerError
11
+ def status_code
12
+ 4
13
+ end
14
+ end
15
+
16
+ class LockfileError < BundlerError
17
+ def status_code
18
+ 5
19
+ end
20
+ end
21
+
22
+ class ResolveError < BundlerError
23
+ def status_code
24
+ 6
25
+ end
26
+ end
27
+
28
+ class NetworkError < BundlerError
29
+ def status_code
30
+ 7
31
+ end
32
+ end
33
+
34
+ class InstallError < BundlerError
35
+ def status_code
36
+ 8
37
+ end
38
+ end
39
+
40
+ class ExtensionBuildError < InstallError
41
+ def status_code
42
+ 9
43
+ end
44
+ end
45
+
46
+ class PermissionError < BundlerError
47
+ def status_code
48
+ 10
49
+ end
50
+ end
51
+
52
+ class PlatformError < BundlerError
53
+ def status_code
54
+ 11
55
+ end
56
+ end
57
+
58
+ class CacheError < BundlerError
59
+ def status_code
60
+ 12
61
+ end
62
+ end
63
+ end
data/lib/scint/fs.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Scint
7
+ module FS
8
+ module_function
9
+
10
+ # Cache of directories we've already ensured exist, to avoid repeated syscalls.
11
+ @mkdir_cache = {}
12
+ @mkdir_mutex = Thread::Mutex.new
13
+
14
+ def mkdir_p(path)
15
+ path = path.to_s
16
+ return if @mkdir_cache[path]
17
+
18
+ # Do the filesystem call outside the cache mutex so unrelated directory
19
+ # creation can proceed in parallel across worker threads.
20
+ FileUtils.mkdir_p(path)
21
+ @mkdir_mutex.synchronize { @mkdir_cache[path] = true }
22
+ end
23
+
24
+ # APFS clonefile (CoW copy). Falls back to hardlink, then regular copy.
25
+ def clonefile(src, dst)
26
+ src = src.to_s
27
+ dst = dst.to_s
28
+ mkdir_p(File.dirname(dst))
29
+
30
+ # Try APFS clonefile via cp -c (macOS)
31
+ if Platform.macos?
32
+ return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
33
+ end
34
+
35
+ # Fallback: hardlink
36
+ begin
37
+ File.link(src, dst)
38
+ return
39
+ rescue SystemCallError
40
+ # cross-device or unsupported
41
+ end
42
+
43
+ # Final fallback: regular copy
44
+ FileUtils.cp(src, dst)
45
+ end
46
+
47
+ # Recursively hardlink all files from src_dir into dst_dir.
48
+ # Directory structure is recreated; files are hardlinked.
49
+ def hardlink_tree(src_dir, dst_dir)
50
+ src_dir = src_dir.to_s
51
+ dst_dir = dst_dir.to_s
52
+ raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
53
+ mkdir_p(dst_dir)
54
+
55
+ queue = [[src_dir, dst_dir]]
56
+ until queue.empty?
57
+ src_root, dst_root = queue.shift
58
+
59
+ Dir.each_child(src_root) do |entry|
60
+ src_path = File.join(src_root, entry)
61
+ dst_path = File.join(dst_root, entry)
62
+ stat = File.lstat(src_path)
63
+
64
+ if stat.directory?
65
+ mkdir_p(dst_path)
66
+ queue << [src_path, dst_path]
67
+ next
68
+ end
69
+
70
+ mkdir_p(File.dirname(dst_path))
71
+ begin
72
+ File.link(src_path, dst_path)
73
+ rescue SystemCallError
74
+ FileUtils.cp(src_path, dst_path)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ # Atomic move: rename with cross-device fallback.
81
+ def atomic_move(src, dst)
82
+ src = src.to_s
83
+ dst = dst.to_s
84
+ mkdir_p(File.dirname(dst))
85
+
86
+ begin
87
+ File.rename(src, dst)
88
+ rescue Errno::EXDEV
89
+ # Cross-device: copy then remove
90
+ tmp = "#{dst}.#{Process.pid}.#{Thread.current.object_id}"
91
+ if File.directory?(src)
92
+ FileUtils.cp_r(src, tmp)
93
+ else
94
+ FileUtils.cp(src, tmp)
95
+ end
96
+ File.rename(tmp, dst)
97
+ FileUtils.rm_rf(src)
98
+ end
99
+ end
100
+
101
+ # Create a temporary directory, yield it, clean up on error.
102
+ def with_tempdir(prefix = "scint")
103
+ dir = Dir.mktmpdir(prefix)
104
+ yield dir
105
+ rescue StandardError
106
+ FileUtils.rm_rf(dir) if dir && File.exist?(dir)
107
+ raise
108
+ end
109
+
110
+ # Write to a temp file then atomically rename into place.
111
+ def atomic_write(path, content)
112
+ path = path.to_s
113
+ mkdir_p(File.dirname(path))
114
+ tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
115
+ File.binwrite(tmp, content)
116
+ File.rename(tmp, path)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/package"
4
+ require "zlib"
5
+ require "fileutils"
6
+ require_relative "../fs"
7
+
8
+ module Scint
9
+ module GemPkg
10
+ class Extractor
11
+ # Extract data.tar.gz contents into dest_dir.
12
+ # Primary: shell out to system tar for speed.
13
+ # Fallback: pure Ruby extraction.
14
+ def extract(data_tar_gz_path, dest_dir)
15
+ FS.mkdir_p(dest_dir)
16
+
17
+ if system_tar_available?
18
+ extract_with_system_tar(data_tar_gz_path, dest_dir)
19
+ else
20
+ extract_with_ruby(data_tar_gz_path, dest_dir)
21
+ end
22
+
23
+ dest_dir
24
+ end
25
+
26
+ private
27
+
28
+ def system_tar_available?
29
+ @system_tar_available = system("tar", "--version", [:out, :err] => File::NULL) if @system_tar_available.nil?
30
+ @system_tar_available
31
+ rescue Errno::ENOENT
32
+ @system_tar_available = false
33
+ end
34
+
35
+ def extract_with_system_tar(data_tar_gz_path, dest_dir)
36
+ result = system("tar", "xzf", data_tar_gz_path, "-C", dest_dir)
37
+ return if result
38
+
39
+ # If system tar fails, fall back to Ruby
40
+ extract_with_ruby(data_tar_gz_path, dest_dir)
41
+ end
42
+
43
+ def extract_with_ruby(data_tar_gz_path, dest_dir)
44
+ File.open(data_tar_gz_path, "rb") do |file|
45
+ gz = Zlib::GzipReader.new(file)
46
+ tar = ::Gem::Package::TarReader.new(gz)
47
+
48
+ tar.each do |entry|
49
+ dest_path = File.join(dest_dir, entry.full_name)
50
+
51
+ # Security: prevent path traversal
52
+ unless safe_path?(dest_dir, dest_path)
53
+ next
54
+ end
55
+
56
+ if entry.directory?
57
+ FS.mkdir_p(dest_path)
58
+ elsif entry.symlink?
59
+ # Only follow symlinks that stay inside dest_dir
60
+ link_target = File.expand_path(entry.header.linkname, File.dirname(dest_path))
61
+ if safe_path?(dest_dir, link_target)
62
+ FS.mkdir_p(File.dirname(dest_path))
63
+ File.symlink(entry.header.linkname, dest_path)
64
+ end
65
+ elsif entry.file?
66
+ FS.mkdir_p(File.dirname(dest_path))
67
+ File.open(dest_path, "wb") do |f|
68
+ while (chunk = entry.read(16384))
69
+ f.write(chunk)
70
+ end
71
+ end
72
+ File.chmod(entry.header.mode, dest_path) if entry.header.mode
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ # Ensure dest_path is inside base_dir (prevent directory traversal).
79
+ def safe_path?(base_dir, dest_path)
80
+ expanded = File.expand_path(dest_path)
81
+ expanded_base = File.expand_path(base_dir)
82
+ expanded.start_with?("#{expanded_base}/") || expanded == expanded_base
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/package"
4
+ require "zlib"
5
+ require "stringio"
6
+ require_relative "extractor"
7
+ require_relative "../fs"
8
+
9
+ module Scint
10
+ module GemPkg
11
+ class Package
12
+ # Read gemspec from a .gem file without full extraction.
13
+ # Returns a Gem::Specification.
14
+ def read_metadata(gem_path)
15
+ File.open(gem_path, "rb") do |io|
16
+ tar = ::Gem::Package::TarReader.new(io)
17
+ tar.each do |entry|
18
+ if entry.full_name == "metadata.gz"
19
+ gz = Zlib::GzipReader.new(StringIO.new(entry.read))
20
+ return ::Gem::Specification.from_yaml(gz.read)
21
+ end
22
+ end
23
+ end
24
+ raise InstallError, "No metadata.gz found in #{gem_path}"
25
+ end
26
+
27
+ # Single-pass extraction: reads the .gem TAR once, extracts both
28
+ # metadata.gz (gemspec) and data.tar.gz (files) in one pass.
29
+ # Returns { gemspec: Gem::Specification, extracted_path: dest_dir }
30
+ def extract(gem_path, dest_dir)
31
+ FS.mkdir_p(dest_dir)
32
+ gemspec = nil
33
+ data_tar_gz = nil
34
+
35
+ File.open(gem_path, "rb") do |io|
36
+ tar = ::Gem::Package::TarReader.new(io)
37
+ tar.each do |entry|
38
+ case entry.full_name
39
+ when "metadata.gz"
40
+ gz = Zlib::GzipReader.new(StringIO.new(entry.read))
41
+ gemspec = ::Gem::Specification.from_yaml(gz.read)
42
+ when "data.tar.gz"
43
+ # Write data.tar.gz to a temp file for extraction
44
+ tmp = File.join(dest_dir, ".data.tar.gz.tmp")
45
+ File.open(tmp, "wb") { |f| f.write(entry.read) }
46
+ data_tar_gz = tmp
47
+ end
48
+ end
49
+ end
50
+
51
+ raise InstallError, "No metadata.gz in #{gem_path}" unless gemspec
52
+
53
+ if data_tar_gz
54
+ Extractor.new.extract(data_tar_gz, dest_dir)
55
+ File.delete(data_tar_gz) if File.exist?(data_tar_gz)
56
+ end
57
+
58
+ { gemspec: gemspec, extracted_path: dest_dir }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scint
4
+ module Gemfile
5
+ # Represents a single gem dependency declared in a Gemfile.
6
+ # This is an intermediate type used during Gemfile parsing;
7
+ # the resolver and installer use the top-level Scint::Dependency struct.
8
+ class Dependency
9
+ attr_reader :name, :version_reqs, :groups, :platforms, :require_paths, :source_options
10
+
11
+ def initialize(name, version_reqs: [], groups: [:default], platforms: [],
12
+ require_paths: nil, source_options: {})
13
+ @name = name.to_s.freeze
14
+ @version_reqs = version_reqs.empty? ? [">= 0"] : version_reqs
15
+ @groups = groups.map(&:to_sym)
16
+ @platforms = platforms.map(&:to_sym)
17
+ @require_paths = require_paths
18
+ @source_options = source_options
19
+ end
20
+
21
+ def to_s
22
+ if version_reqs == [">= 0"]
23
+ name
24
+ else
25
+ "#{name} (#{version_reqs.join(", ")})"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Scint
6
+ module Gemfile
7
+ # Lightweight text editor for common Gemfile dependency updates.
8
+ #
9
+ # Handles single-line `gem` declarations, which is enough for the
10
+ # fast-path workflows used by `scint add` and `scint remove`.
11
+ class Editor
12
+ def initialize(path = "Gemfile")
13
+ @path = path
14
+ end
15
+
16
+ def add(name, requirement: nil, group: nil, source: nil, git: nil, path: nil)
17
+ content = read
18
+ line = build_line(
19
+ name,
20
+ requirement: requirement,
21
+ group: group,
22
+ source: source,
23
+ git: git,
24
+ path: path,
25
+ )
26
+
27
+ updated = false
28
+ out_lines = content.lines.map do |l|
29
+ if gem_line_for?(l, name)
30
+ updated = true
31
+ "#{line}\n"
32
+ else
33
+ l
34
+ end
35
+ end
36
+
37
+ unless updated
38
+ out_lines << "\n" unless out_lines.empty? || out_lines.last.end_with?("\n\n")
39
+ out_lines << "#{line}\n"
40
+ end
41
+
42
+ write(out_lines.join)
43
+ updated ? :updated : :added
44
+ end
45
+
46
+ def remove(name)
47
+ content = read
48
+ removed = false
49
+
50
+ out_lines = content.lines.reject do |line|
51
+ match = gem_line_for?(line, name)
52
+ removed ||= match
53
+ match
54
+ end
55
+
56
+ write(out_lines.join)
57
+ removed
58
+ end
59
+
60
+ private
61
+
62
+ def read
63
+ unless File.exist?(@path)
64
+ raise GemfileError, "Gemfile not found at #{@path}"
65
+ end
66
+
67
+ File.read(@path)
68
+ end
69
+
70
+ def write(content)
71
+ File.write(@path, content)
72
+ end
73
+
74
+ def gem_line_for?(line, name)
75
+ line.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["'](?:\s|,|$)/)
76
+ end
77
+
78
+ def build_line(name, requirement:, group:, source:, git:, path:)
79
+ parts = ["gem #{name.inspect}"]
80
+ parts << requirement.inspect if requirement && !requirement.empty?
81
+
82
+ opts = []
83
+ opts << "group: :#{group}" if group
84
+ opts << "source: #{source.inspect}" if source
85
+ opts << "git: #{git.inspect}" if git
86
+ opts << "path: #{path.inspect}" if path
87
+
88
+ parts << opts.join(", ") unless opts.empty?
89
+ parts.join(", ")
90
+ end
91
+ end
92
+ end
93
+ end