carton 0.1.0 → 0.3.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/README.md +103 -27
  4. data/lib/carton/activate_gems.rb +8 -0
  5. data/lib/carton/box.rb +162 -68
  6. data/lib/carton/bundler.rb +73 -0
  7. data/lib/carton/error.rb +24 -0
  8. data/lib/carton/gem_activation.rb +151 -0
  9. data/lib/carton/import_cache.rb +105 -0
  10. data/lib/carton/kernel_patch.rb +12 -8
  11. data/lib/carton/loaded_features.rb +10 -0
  12. data/lib/carton/namespace.rb +78 -0
  13. data/lib/carton/{export_methods.rb → namespace_methods.rb} +14 -3
  14. data/lib/carton/resolver.rb +149 -0
  15. data/lib/carton/runtime.rb +74 -59
  16. data/lib/carton/version.rb +1 -1
  17. data/lib/carton.rb +13 -22
  18. data/sig/carton.rbs +49 -0
  19. metadata +20 -34
  20. data/AGENTS.md +0 -47
  21. data/DESIGN.md +0 -84
  22. data/Rakefile +0 -52
  23. data/TODO.md +0 -33
  24. data/USAGE.md +0 -173
  25. data/examples/complex/README.md +0 -33
  26. data/examples/complex/cartons/adventure/.env +0 -1
  27. data/examples/complex/cartons/adventure/Gemfile +0 -5
  28. data/examples/complex/cartons/adventure/Gemfile.lock +0 -17
  29. data/examples/complex/cartons/adventure/lib/adventure.rb +0 -12
  30. data/examples/complex/cartons/loot/.env +0 -1
  31. data/examples/complex/cartons/loot/Gemfile +0 -5
  32. data/examples/complex/cartons/loot/Gemfile.lock +0 -17
  33. data/examples/complex/cartons/loot/lib/loot.rb +0 -47
  34. data/examples/complex/cartons/quest/lib/quest.rb +0 -6
  35. data/examples/complex/main.rb +0 -37
  36. data/examples/minimal/README.md +0 -16
  37. data/examples/minimal/bar.rb +0 -10
  38. data/examples/minimal/baz.rb +0 -7
  39. data/examples/minimal/foo.rb +0 -7
  40. data/examples/minimal/main.rb +0 -17
  41. data/images/carton.png +0 -0
  42. data/lib/carton/exports.rb +0 -35
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ class << self
5
+ # Set up the current carton's bundle, like `Bundler.setup(*groups)`.
6
+ #
7
+ # Bundler finds its Gemfile through process-global `ENV["BUNDLE_GEMFILE"]`
8
+ # or the real process cwd. Carton resolves the Gemfile from the calling
9
+ # file (or `gemfile:`, relative to it), points Bundler at it, and clears any
10
+ # stale `BUNDLE_LOCKFILE` so Bundler derives the matching lockfile. All of
11
+ # `ENV` is restored afterwards; Bundler keeps the selected Gemfile.
12
+ def setup_bundle(*groups, gemfile: nil)
13
+ path = resolve_bundle_gemfile(gemfile, caller_locations(1, 1).first)
14
+
15
+ with_bundle_env(path) do
16
+ require 'bundler'
17
+ Bundler.ui.silence { Bundler.setup(*groups) }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # `ENV` is process-global. Bundler reads the Gemfile from it and exports
24
+ # subprocess variables such as `RUBYOPT` into it, so restore all of it.
25
+ def with_bundle_env(gemfile)
26
+ previous_env = ENV.to_h
27
+ ENV['BUNDLE_GEMFILE'] = gemfile
28
+ ENV.delete 'BUNDLE_LOCKFILE'
29
+ yield
30
+ ensure
31
+ ENV.replace(previous_env)
32
+ end
33
+
34
+ def resolve_bundle_gemfile(gemfile, call_site)
35
+ caller_dir = caller_directory(call_site)
36
+ return resolve_explicit_bundle_gemfile(gemfile, caller_dir) if gemfile
37
+
38
+ current = caller_dir
39
+
40
+ loop do
41
+ bundle_gemfile_names.each do |name|
42
+ candidate = File.join(current, name)
43
+ return candidate if File.file?(candidate)
44
+ end
45
+
46
+ parent = File.dirname(current)
47
+ break if parent == current
48
+
49
+ current = parent
50
+ end
51
+
52
+ raise GemfileNotFound, "Gemfile not found from #{caller_dir}"
53
+ end
54
+
55
+ def resolve_explicit_bundle_gemfile(gemfile, caller_dir)
56
+ resolved = File.expand_path(gemfile, caller_dir)
57
+ return resolved if File.file?(resolved)
58
+
59
+ raise GemfileNotFound, "Gemfile not found: #{resolved}"
60
+ end
61
+
62
+ def caller_directory(call_site)
63
+ path = call_site&.absolute_path || call_site&.path
64
+ return Dir.pwd unless path && !path.start_with?('(')
65
+
66
+ File.dirname(File.expand_path(path))
67
+ end
68
+
69
+ def bundle_gemfile_names
70
+ %w[gems.rb Gemfile]
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ # Base class for every error Carton raises itself. A missing Import target
5
+ # raises `LoadError`, as `require` does, and a missing exported name raises
6
+ # `NameError`.
7
+ class Error < StandardError
8
+ end
9
+
10
+ # An Import that cannot go ahead: Boxes are disabled, a Carton imports
11
+ # itself, or a cached Carton was built against other gem versions.
12
+ class ImportError < Error
13
+ end
14
+
15
+ # An `export` that breaks the rules: called twice in one Entrypoint, given
16
+ # a Symbol, or a Bare Carton helper that would hide a `Carton::Namespace`
17
+ # method.
18
+ class ExportError < Error
19
+ end
20
+
21
+ # Raised when Carton cannot find the bundle file for `setup_bundle`.
22
+ class GemfileNotFound < Error
23
+ end
24
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ # Carries the caller's activated gems into a Carton Box.
5
+ #
6
+ # Optional Boxes copy Master, so they start with clean RubyGems state and
7
+ # would auto-activate the newest installed version of any dependency. When
8
+ # an Import resolves inside an activated gem, Carton activates that gem and
9
+ # its runtime dependencies in the new Box at the caller's versions, so the
10
+ # caller's lockfile (or plain `gem` calls) decides what the Carton sees.
11
+ #
12
+ # When the caller ran Bundler setup, the Box is also restricted to the
13
+ # caller's locked gems, as `Bundler.setup` restricts Main: every locked gem
14
+ # is activated at its locked version and anything else fails to load.
15
+ module GemActivation
16
+ module_function
17
+
18
+ # The gem in `loaded_specs` that owns `feature`, plus its activated
19
+ # runtime dependencies, dependencies first. Default gems count: a new Box
20
+ # would otherwise load the newest installed version, not the caller's.
21
+ # Empty when no activated gem owns the file.
22
+ def closure(feature, loaded_specs)
23
+ owner = owner_of(feature, loaded_specs.values)
24
+ return [] unless owner
25
+
26
+ visit(owner, loaded_specs, [], {})
27
+ end
28
+
29
+ # The caller's locked gems when `box` ran Bundler setup, otherwise nil.
30
+ # Bundler marks every locked spec as loaded, so these are the shareable
31
+ # entries of its `Gem.loaded_specs`.
32
+ def locked(box, loaded_specs)
33
+ return unless bundled?(box)
34
+
35
+ loaded_specs.values.select { |spec| shareable?(spec) }
36
+ end
37
+
38
+ # The specs that own at least one of `features`, by require path. Sorted
39
+ # features let each directory check be a binary search, so a large bundle
40
+ # stays cheap.
41
+ def owning(specs, features)
42
+ sorted = features.sort
43
+ specs.select do |spec|
44
+ spec.full_require_paths.any? do |dir|
45
+ prefix = "#{dir}/"
46
+ sorted.bsearch { |feature| feature >= prefix }&.start_with?(prefix)
47
+ end
48
+ end
49
+ end
50
+
51
+ # Rebuild specs in the current Box and activate them. With `locked`, the
52
+ # Box is restricted to those specs and all of them are activated, as
53
+ # `Bundler.setup` does, since RubyGems does not auto-activate under
54
+ # Bundler's restricted view. Spec objects come from another Box, so their
55
+ # own `activate` would update that Box; a YAML round trip builds this Box's
56
+ # `Gem::Specification` without re-evaluating gemspec files.
57
+ #
58
+ # Default gems come first, from this Box's own default specs: the YAML
59
+ # round trip loads Psych, whose requires (date, for one) would otherwise
60
+ # activate the newest installed version before the caller's.
61
+ def activate(specs, locked = nil)
62
+ defaults, others = (locked || specs).partition(&:default_gem?)
63
+ defaults.each { |source| activate_spec(default_spec(source)) }
64
+
65
+ rebuilt = others.map { |source| rebuild(source) }
66
+ restrict_to(rebuilt) if locked
67
+ rebuilt.each { |spec| activate_spec(spec) }
68
+ end
69
+
70
+ def activate_spec(spec)
71
+ loaded = Gem.loaded_specs[spec.name]
72
+ spec.activate unless loaded && loaded.version == spec.version
73
+ end
74
+ private_class_method :activate_spec
75
+
76
+ def default_spec(source)
77
+ stub =
78
+ Gem::Specification.default_stubs(
79
+ "#{source.name}-#{source.version}.gemspec",
80
+ ).first
81
+ stub ? stub.to_spec : rebuild(source)
82
+ end
83
+ private_class_method :default_spec
84
+
85
+ def rebuild(source)
86
+ spec = Gem::Specification.from_yaml(source.to_spec.to_yaml)
87
+ spec.loaded_from = source.loaded_from
88
+ spec.full_gem_path = source.full_gem_path
89
+ spec
90
+ end
91
+ private_class_method :rebuild
92
+
93
+ # Bundler has no public API for "restrict this Box to these specs", so the
94
+ # two Bundler internals Carton relies on live here. `@replaced_methods`
95
+ # is non-empty once `Bundler.setup` has replaced RubyGems entrypoints.
96
+ # `replace_entrypoints` is the strict half of `Bundler::Runtime#setup`,
97
+ # without resolution, `ENV` exports, or lockfile writes. See
98
+ # docs/internals/upstream.md.
99
+ def bundled?(box)
100
+ box.eval(
101
+ 'defined?(Bundler.rubygems) && ' \
102
+ 'Bundler.rubygems.instance_variable_get(:@replaced_methods)&.any?',
103
+ )
104
+ end
105
+ private_class_method :bundled?
106
+
107
+ def restrict_to(specs)
108
+ require 'bundler'
109
+ Bundler.rubygems.replace_entrypoints(specs)
110
+ end
111
+ private_class_method :restrict_to
112
+
113
+ def owner_of(feature, specs)
114
+ specs
115
+ .select { |spec| activatable?(spec) }
116
+ .filter_map do |spec|
117
+ dir =
118
+ spec.full_require_paths.find do |path|
119
+ feature.start_with?("#{path}/")
120
+ end
121
+ [dir.length, spec] if dir
122
+ end
123
+ .max_by(&:first)
124
+ &.last
125
+ end
126
+ private_class_method :owner_of
127
+
128
+ def visit(spec, loaded_specs, ordered, seen)
129
+ return ordered if seen[spec.name] || !activatable?(spec)
130
+
131
+ seen[spec.name] = true
132
+ spec.runtime_dependencies.each do |dependency|
133
+ dependency_spec = loaded_specs[dependency.name]
134
+ visit(dependency_spec, loaded_specs, ordered, seen) if dependency_spec
135
+ end
136
+ ordered << spec
137
+ end
138
+ private_class_method :visit
139
+
140
+ # Bundler is never shared: each Box loads its own copy to apply the
141
+ # restriction, possibly a different version than the caller's, and
142
+ # activating the caller's on top of it raises Gem::LoadError.
143
+ def activatable?(spec) = spec.name != 'bundler'
144
+ private_class_method :activatable?
145
+
146
+ # Bundler's restriction already lets default gems load, so the locked set
147
+ # leaves them out.
148
+ def shareable?(spec) = activatable?(spec) && !spec.default_gem?
149
+ private_class_method :shareable?
150
+ end
151
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ # Process-wide record of loaded Cartons, keyed by Entrypoint path.
5
+ #
6
+ # The Main Box creates one instance and every Carton Box receives that same
7
+ # object, so each Entrypoint loads once no matter which Carton imports it.
8
+ # Ruby objects are shared across Boxes; only definitions are Box-local, so
9
+ # the Main Box's loader builds every Carton. A cached value is then a
10
+ # value or `Carton::Exports` from the Main Box regardless of which
11
+ # Carton imported it first.
12
+ class ImportCache
13
+ # Returned by `claim` when the calling thread should load the Entrypoint.
14
+ LOAD = Object.new.freeze
15
+ private_constant :LOAD
16
+
17
+ def initialize(&loader)
18
+ @loader = loader
19
+ @values = {}
20
+ @gem_usage = {}
21
+ # The thread loading each Entrypoint, while it loads.
22
+ @loading = {}
23
+ @lock = Thread::Mutex.new
24
+ @loaded = Thread::ConditionVariable.new
25
+ end
26
+
27
+ # Load `target` once per Entrypoint path. Targets without an absolute
28
+ # path are loaded uncached.
29
+ #
30
+ # The lock only guards this bookkeeping and is never held while an
31
+ # Entrypoint runs, so an Entrypoint can start threads that import other
32
+ # Cartons. An import of an Entrypoint another thread is loading waits
33
+ # for that load; one the same thread is loading is a cycle.
34
+ def import(target)
35
+ return @loader.call(target) unless File.absolute_path?(target.feature)
36
+
37
+ key = File.realpath(target.feature)
38
+ cached = claim(key)
39
+ return load_entry(key, target) if cached.equal?(LOAD)
40
+
41
+ check_gem_versions(key, target)
42
+ cached
43
+ end
44
+
45
+ private
46
+
47
+ # The cached value, or LOAD once this thread owns loading `key`.
48
+ def claim(key)
49
+ @lock.synchronize do
50
+ loop do
51
+ return @values[key] if @values.key?(key)
52
+
53
+ owner = @loading[key]
54
+ if owner.nil?
55
+ @loading[key] = Thread.current
56
+ return LOAD
57
+ end
58
+ if owner == Thread.current
59
+ raise ImportError, "circular import: #{key}"
60
+ end
61
+
62
+ @loaded.wait(@lock)
63
+ end
64
+ end
65
+ end
66
+
67
+ # A failed load is not cached: the next import, or a thread waiting on
68
+ # this one, tries again.
69
+ def load_entry(key, target)
70
+ value = @loader.call(target)
71
+ @lock.synchronize do
72
+ @values[key] = value
73
+ @gem_usage[key] = target.gem_usage || -> { target.gems }
74
+ end
75
+ value
76
+ ensure
77
+ @lock.synchronize do
78
+ @loading.delete(key)
79
+ @loaded.broadcast
80
+ end
81
+ end
82
+
83
+ # A cached Carton keeps the gem versions its first importer gave it: its
84
+ # closure and the locked gems it has loaded, including any loaded lazily
85
+ # since. Raise instead of silently sharing it with an importer that
86
+ # activates or locks another version of one.
87
+ def check_gem_versions(key, target)
88
+ wanted = versions([*target.locked_gems, *target.gems])
89
+ return if wanted.empty?
90
+
91
+ usage = @lock.synchronize { @gem_usage.fetch(key) }
92
+ versions(usage.call).each do |name, version|
93
+ next if wanted.fetch(name, version) == version
94
+
95
+ raise ImportError,
96
+ "#{key} was imported with #{name} #{version}, " \
97
+ "but this import activates #{name} #{wanted[name]}"
98
+ end
99
+ end
100
+
101
+ def versions(specs)
102
+ (specs || []).to_h { |spec| [spec.name, spec.version.to_s] }
103
+ end
104
+ end
105
+ end
@@ -2,24 +2,28 @@
2
2
 
3
3
  module Carton
4
4
  module KernelPatch
5
+ private # like `require` and `require_relative`
6
+
7
+ # Import a carton by absolute path or by feature name from the caller box's
8
+ # current `$LOAD_PATH`.
5
9
  def import(path)
6
- Runtime.import(path, base_dir: Dir.pwd)
10
+ Runtime.import(path, from: caller_locations(1, 1).first.path)
7
11
  end
8
12
 
13
+ # Import a carton relative to the calling file, like `require_relative`.
9
14
  def import_relative(path)
10
15
  caller_dir = File.dirname(caller_locations(1, 1).first.path)
11
16
  Runtime.import(path, base_dir: caller_dir)
12
17
  end
13
18
 
14
- def export(*args, **kwargs)
15
- Runtime.export(args, kwargs)
16
- end
17
-
18
- def export_default(value)
19
- Runtime.export_default(value)
19
+ # Export one value from the current carton; `import` returns it as is.
20
+ # Keywords export a Hash, as in `export version: '1.0'`.
21
+ def export(value)
22
+ file = caller_locations(1, 1).first.absolute_path
23
+ Runtime.export(value, file:)
20
24
  end
21
25
  end
22
26
  end
23
27
 
24
28
  # Inject only the Kernel patch module into Kernel.
25
- ::Kernel.prepend(Carton::KernelPatch)
29
+ Kernel.prepend(Carton::KernelPatch)
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded inside a Carton Box by `Carton::Box#loaded_features`. Like
4
+ # `$LOAD_PATH`, `$LOADED_FEATURES` resolves to the Box of the file being
5
+ # loaded, so this file sees the Carton's own features even while another
6
+ # Carton is importing it.
7
+ Carton
8
+ .const_get(:Box)
9
+ .for(Ruby::Box.current)
10
+ .__send__(:receive_loaded_features, $LOADED_FEATURES.to_a)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ # What importing a Bare Carton returns: a module of the constants and
5
+ # top-level helpers its Entrypoint defined. Helpers become singleton
6
+ # methods that forward to the Carton's method, and lookup helpers return
7
+ # the `Method` object or the constant.
8
+ class Namespace < ::Module
9
+ include NamespaceMethods
10
+
11
+ # A helper becomes a singleton method, so one named like a method every
12
+ # `Carton::Namespace` has, such as `fetch` or `name`, would hide it.
13
+ # Constants are capitalized and never collide.
14
+ def self.reserved?(name)
15
+ name = name.to_s
16
+ !name.match?(/\A[A-Z]/) && RESERVED.include?(name)
17
+ end
18
+
19
+ # Raise for the first reserved name in `names`.
20
+ def self.check_names(names)
21
+ name = names.find { |n| reserved?(n) } or return
22
+
23
+ raise ExportError,
24
+ "a top-level method named #{name} would hide " \
25
+ "Carton::Namespace##{name}: rename it, or export a module"
26
+ end
27
+
28
+ def initialize(methods = {}, constants = {})
29
+ super()
30
+ Namespace.check_names(methods.keys)
31
+ @lookup = {}
32
+ define_methods(methods)
33
+ define_constants(constants)
34
+ end
35
+
36
+ def deconstruct_keys(keys)
37
+ return super if keys
38
+
39
+ to_h
40
+ end
41
+
42
+ # `#<Carton::Namespace PI, add>`: its names, so a namespace is
43
+ # recognizable in a console.
44
+ def inspect
45
+ names = @lookup.keys.join(', ')
46
+ names.empty? ? '#<Carton::Namespace>' : "#<Carton::Namespace #{names}>"
47
+ end
48
+ alias to_s inspect
49
+
50
+ private
51
+
52
+ def define_methods(methods)
53
+ methods.each do |name, method|
54
+ @lookup[name.to_s] = method
55
+ define_singleton_method(name) do |*args, **kwargs, &block|
56
+ method.call(*args, **kwargs, &block)
57
+ end
58
+ end
59
+ end
60
+
61
+ def define_constants(constants)
62
+ constants.each do |name, value|
63
+ @lookup[name.to_s] = value
64
+ const_set(name, value)
65
+ end
66
+ end
67
+
68
+ def lookup_entry(key)
69
+ name = key.to_s
70
+ @lookup.key?(name) ? [true, @lookup[name]] : [false, nil]
71
+ end
72
+
73
+ def lookup_names = @lookup.keys
74
+
75
+ RESERVED = public_instance_methods.map(&:to_s).freeze
76
+ private_constant :RESERVED
77
+ end
78
+ end
@@ -1,27 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
- module ExportMethods
4
+ # Hash-like lookup helpers for `Carton::Namespace`.
5
+ module NamespaceMethods
6
+ # Support destructuring like `import('math') => { add: }`.
5
7
  def deconstruct_keys(keys)
6
- return {} unless keys
7
-
8
8
  keys.each_with_object({}) do |key, hash|
9
9
  found, value = lookup_entry(key)
10
10
  hash[key] = value if found
11
11
  end
12
12
  end
13
13
 
14
+ # Return the member or `nil`.
14
15
  def [](key)
15
16
  found, value = lookup_entry(key)
16
17
  found ? value : nil
17
18
  end
18
19
 
20
+ # Check whether the lookup will succeed.
19
21
  def key?(key)
20
22
  lookup_entry(key).first
21
23
  end
22
24
 
23
25
  alias has_key? key?
24
26
 
27
+ # Fetch a member with the same default/block semantics as `Hash#fetch`.
25
28
  def fetch(key, *default)
26
29
  if default.size > 1
27
30
  raise ArgumentError,
@@ -36,10 +39,18 @@ module Carton
36
39
  raise KeyError, "key not found: #{key.inspect}"
37
40
  end
38
41
 
42
+ # Member names as Symbols: helpers first, then constants.
43
+ def keys = lookup_names.map(&:to_sym)
44
+
45
+ # Every member by name: helpers as `Method` objects, constants as set.
46
+ def to_h = keys.to_h { |key| [key, self[key]] }
47
+
48
+ # Fetch several members and raise if any are missing.
39
49
  def fetch_values(*keys)
40
50
  keys.map { |key| fetch(key) }
41
51
  end
42
52
 
53
+ # Return several members, using `nil` for missing keys.
43
54
  def values_at(*keys)
44
55
  keys.map { |key| self[key] }
45
56
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ # Maps an Import path to the file that should load, without evaluating any
5
+ # code or reading Box state. The runtime passes the caller Box's
6
+ # `$LOAD_PATH`; a static analyzer can pass any load path it has computed.
7
+ module Resolver
8
+ # `feature` is what the new Box requires. `load_path` is the single caller
9
+ # load-path entry that matched a name-based Import, if any. The runtime
10
+ # fills `gems` with the caller specs to activate in the new Box, and
11
+ # `locked_gems` with the caller's locked specs when it ran Bundler setup.
12
+ # After loading, the Carton Box sets `gem_usage`, a callable returning the
13
+ # gems the Carton depends on at that moment: its closure plus every locked
14
+ # gem whose files it has loaded so far.
15
+ Target =
16
+ Struct.new(
17
+ :feature,
18
+ :load_path,
19
+ :gems,
20
+ :locked_gems,
21
+ :gem_usage,
22
+ keyword_init: true,
23
+ )
24
+
25
+ EXTENSIONS = ['.rb', ".#{RbConfig::CONFIG['DLEXT']}"].freeze
26
+ private_constant :EXTENSIONS
27
+
28
+ module_function
29
+
30
+ # Resolve `path` like `require_relative` when `base_dir` is given, like
31
+ # `require` with an absolute path, or by searching `load_path` in order.
32
+ # A missing relative or absolute target raises LoadError, as
33
+ # `require_relative` does. A name that matches no Ruby source file, such
34
+ # as a native extension, comes back as the bare `path`. A file inside a
35
+ # Carton raises ImportError: only its Entrypoint can be imported.
36
+ def resolve(path, load_path:, base_dir: nil)
37
+ target = locate(path, load_path:, base_dir:)
38
+ reject_inside_carton(path.to_s, target)
39
+ target
40
+ end
41
+
42
+ # Like `resolve`, but a file inside a Carton comes back as is, as
43
+ # `require` would load it. `carton check` uses this for `require` calls.
44
+ def locate(path, load_path:, base_dir: nil)
45
+ path = path.to_s
46
+ if base_dir || File.absolute_path?(path)
47
+ resolve_file(File.expand_path(path, base_dir))
48
+ else
49
+ resolve_name(path, load_path) || Target.new(feature: path)
50
+ end
51
+ end
52
+
53
+ # A Carton is its Entrypoint `x.rb` plus the `x/` directory beside it,
54
+ # the shape of a gem's `lib/` and of a Zeitwerk explicit namespace.
55
+ # Directories without their own `.rb` file, like Zeitwerk implicit
56
+ # namespaces, stay inside the Carton above them. Returns the outermost
57
+ # Entrypoint whose directory holds `file`, or `file` itself.
58
+ def entrypoint_for(file)
59
+ entrypoint = file
60
+ dir = File.dirname(file)
61
+ until dir == File.dirname(dir)
62
+ entrypoint = "#{dir}.rb" if File.file?("#{dir}.rb")
63
+ dir = File.dirname(dir)
64
+ end
65
+ entrypoint
66
+ end
67
+
68
+ def reject_inside_carton(path, target)
69
+ return unless File.absolute_path?(target.feature)
70
+
71
+ entrypoint = entrypoint_for(target.feature)
72
+ return if entrypoint == target.feature
73
+
74
+ raise ImportError, inside_carton_message(path, target, entrypoint)
75
+ end
76
+ private_class_method :reject_inside_carton
77
+
78
+ def inside_carton_message(path, target, entrypoint)
79
+ prefix = "#{target.load_path}/"
80
+ fix =
81
+ if target.load_path && entrypoint.start_with?(prefix)
82
+ name = entrypoint.delete_prefix(prefix).delete_suffix('.rb')
83
+ "#{path} is inside the #{name} Carton: import '#{name}'"
84
+ else
85
+ "#{target.feature} is inside the Carton #{entrypoint}: " \
86
+ 'import that file'
87
+ end
88
+ "#{fix}, or require it from inside that Carton"
89
+ end
90
+ private_class_method :inside_carton_message
91
+
92
+ # A directory laid out like a gem, `billing/lib/billing.rb`, resolves to
93
+ # that Entrypoint with its `lib/` on the Carton's load path, so the
94
+ # Carton can `require 'billing/invoice'` as a gem would.
95
+ def resolve_file(expanded)
96
+ [expanded, "#{expanded}.rb"].each do |candidate|
97
+ return Target.new(feature: candidate) if File.file?(candidate)
98
+ end
99
+ lib = File.join(expanded, 'lib')
100
+ entrypoint = File.join(lib, "#{File.basename(expanded)}.rb")
101
+ if File.file?(entrypoint)
102
+ return Target.new(feature: entrypoint, load_path: lib)
103
+ end
104
+
105
+ raise LoadError, missing_file_message(expanded)
106
+ end
107
+ private_class_method :resolve_file
108
+
109
+ # Name the files tried, relative to the directory they were tried in.
110
+ def missing_file_message(expanded)
111
+ name = File.basename(expanded)
112
+ tried =
113
+ if EXTENSIONS.include?(File.extname(name))
114
+ [name]
115
+ else
116
+ ["#{name}.rb", "#{name}/lib/#{name}.rb"]
117
+ end
118
+ "cannot load such file -- #{expanded} " \
119
+ "(tried #{tried.join(' and ')} in #{File.dirname(expanded)})"
120
+ end
121
+ private_class_method :missing_file_message
122
+
123
+ # Like Ruby, check each load-path entry in order and try each extension
124
+ # within it, so an earlier native extension shadows a later `.rb`.
125
+ def resolve_name(path, load_path)
126
+ candidates = feature_candidates(path)
127
+
128
+ load_path.each do |entry|
129
+ dir = File.expand_path(entry.to_s)
130
+ candidates.each do |candidate|
131
+ file = File.join(dir, candidate)
132
+ next unless File.file?(file)
133
+ return nil unless file.end_with?('.rb')
134
+
135
+ return Target.new(feature: file, load_path: dir)
136
+ end
137
+ end
138
+ nil
139
+ end
140
+ private_class_method :resolve_name
141
+
142
+ def feature_candidates(path)
143
+ return [path] if EXTENSIONS.include?(File.extname(path))
144
+
145
+ EXTENSIONS.map { |ext| "#{path}#{ext}" }
146
+ end
147
+ private_class_method :feature_candidates
148
+ end
149
+ end