carton 0.1.0 → 0.2.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +98 -26
  4. data/lib/carton/activate_gems.rb +8 -0
  5. data/lib/carton/box.rb +96 -69
  6. data/lib/carton/bundler.rb +73 -0
  7. data/lib/carton/error.rb +23 -0
  8. data/lib/carton/export_declaration.rb +72 -0
  9. data/lib/carton/export_methods.rb +7 -2
  10. data/lib/carton/exports.rb +27 -8
  11. data/lib/carton/gem_activation.rb +117 -0
  12. data/lib/carton/import_cache.rb +70 -0
  13. data/lib/carton/kernel_patch.rb +15 -5
  14. data/lib/carton/resolver.rb +120 -0
  15. data/lib/carton/runtime.rb +74 -53
  16. data/lib/carton/version.rb +1 -1
  17. data/lib/carton.rb +12 -19
  18. data/sig/carton.rbs +42 -0
  19. metadata +17 -31
  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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ # What an Entrypoint exports: an Export Namespace built from one or more
5
+ # `export` calls, or a single Default Export from `export_default`. The two
6
+ # forms are exclusive, and a name may be exported only once.
7
+ class ExportDeclaration
8
+ COMBINED =
9
+ 'export_default cannot be combined with export; ' \
10
+ 'put the other names inside the default value'
11
+ private_constant :COMBINED
12
+
13
+ NO_DEFAULT = Object.new.freeze
14
+ private_constant :NO_DEFAULT
15
+
16
+ def self.absent
17
+ new({}, {}, NO_DEFAULT)
18
+ end
19
+
20
+ def initialize(methods, values, default)
21
+ @methods = methods.freeze
22
+ @values = values.freeze
23
+ @default = default
24
+ freeze
25
+ end
26
+
27
+ def add(methods, values)
28
+ raise ExportError, COMBINED if default?
29
+
30
+ duplicate =
31
+ (methods.keys + values.keys).find do |name|
32
+ @methods.key?(name) || @values.key?(name)
33
+ end
34
+ if duplicate
35
+ raise ExportError, "export name given more than once: #{duplicate}"
36
+ end
37
+
38
+ self.class.__send__(
39
+ :new,
40
+ @methods.merge(methods),
41
+ @values.merge(values),
42
+ NO_DEFAULT,
43
+ )
44
+ end
45
+
46
+ def default(value)
47
+ if default?
48
+ raise ExportError, 'export_default may be called only once per file'
49
+ end
50
+ raise ExportError, COMBINED if namespace?
51
+
52
+ self.class.__send__(:new, @methods, @values, value)
53
+ end
54
+
55
+ # The Default Export, or yields methods and values for the Export
56
+ # Namespace, or for a Bare Carton's surface, which `bare` computes lazily,
57
+ # when nothing was exported.
58
+ def public_surface(bare:)
59
+ return @default if default?
60
+
61
+ namespace? ? yield(@methods, @values) : yield(*bare.call)
62
+ end
63
+
64
+ private_class_method :new
65
+
66
+ private
67
+
68
+ def default? = !NO_DEFAULT.equal?(@default)
69
+
70
+ def namespace? = !(@methods.empty? && @values.empty?)
71
+ end
72
+ end
@@ -1,27 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
+ # Hash-like lookup helpers for `Carton::Exports`.
4
5
  module ExportMethods
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 exported value 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 export 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 an exported value 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,12 @@ module Carton
36
39
  raise KeyError, "key not found: #{key.inspect}"
37
40
  end
38
41
 
42
+ # Fetch several exported values and raise if any are missing.
39
43
  def fetch_values(*keys)
40
44
  keys.map { |key| fetch(key) }
41
45
  end
42
46
 
47
+ # Return several exported values, using `nil` for missing keys.
43
48
  def values_at(*keys)
44
49
  keys.map { |key| self[key] }
45
50
  end
@@ -1,28 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
+ # Module-like wrapper returned from named exports.
5
+ #
6
+ # Exported methods become singleton methods that forward to the carton's
7
+ # method. Capitalized value keys become constants; lowercase value keys become
8
+ # readers that return the value as-is. Lookup helpers return the `Method`
9
+ # object or the value.
4
10
  class Exports < ::Module
5
11
  include ExportMethods
6
12
 
7
- def initialize(values = {})
13
+ def initialize(methods = {}, values = {})
8
14
  super()
9
- @values = values
10
15
  @lookup = {}
11
- define_exports
16
+ define_methods(methods)
17
+ define_values(values)
18
+ end
19
+
20
+ def deconstruct_keys(keys)
21
+ return super if keys
22
+
23
+ @lookup.to_h { |key, value| [key.to_sym, value] }
12
24
  end
13
25
 
14
26
  private
15
27
 
16
- def define_exports
17
- @values.each do |key, value|
28
+ def define_methods(methods)
29
+ methods.each do |name, method|
30
+ @lookup[name.to_s] = method
31
+ define_singleton_method(name) do |*args, **kwargs, &block|
32
+ method.call(*args, **kwargs, &block)
33
+ end
34
+ end
35
+ end
36
+
37
+ def define_values(values)
38
+ values.each do |key, value|
18
39
  @lookup[key.to_s] = value
19
40
 
20
41
  if key.to_s.match?(/\A[A-Z]/)
21
42
  const_set(key, value)
22
43
  else
23
- define_singleton_method(
24
- key,
25
- ) { |*args, **kwargs, &block| value.respond_to?(:call) ? value.call(*args, **kwargs, &block) : value }
44
+ define_singleton_method(key) { value }
26
45
  end
27
46
  end
28
47
  end
@@ -0,0 +1,117 @@
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 non-default gem in `loaded_specs` that owns `feature`, plus its
19
+ # activated runtime dependencies, dependencies first. Empty when no
20
+ # activated gem owns the file.
21
+ def closure(feature, loaded_specs)
22
+ owner = owner_of(feature, loaded_specs.values)
23
+ return [] unless owner
24
+
25
+ visit(owner, loaded_specs, [], {})
26
+ end
27
+
28
+ # The caller's locked gems when `box` ran Bundler setup, otherwise nil.
29
+ # Bundler marks every locked spec as loaded, so these are the shareable
30
+ # entries of its `Gem.loaded_specs`.
31
+ def locked(box, loaded_specs)
32
+ return unless bundled?(box)
33
+
34
+ loaded_specs.values.select { |spec| shareable?(spec) }
35
+ end
36
+
37
+ # Rebuild specs in the current Box and activate them. With `locked`, the
38
+ # Box is restricted to those specs and all of them are activated, as
39
+ # `Bundler.setup` does, since RubyGems does not auto-activate under
40
+ # Bundler's restricted view. Spec objects come from another Box, so their
41
+ # own `activate` would update that Box; a YAML round trip builds this Box's
42
+ # `Gem::Specification` without re-evaluating gemspec files.
43
+ def activate(specs, locked = nil)
44
+ rebuilt = (locked || specs).map { |source| rebuild(source) }
45
+ restrict_to(rebuilt) if locked
46
+
47
+ rebuilt.each do |spec|
48
+ loaded = Gem.loaded_specs[spec.name]
49
+ spec.activate unless loaded && loaded.version == spec.version
50
+ end
51
+ end
52
+
53
+ def rebuild(source)
54
+ spec = Gem::Specification.from_yaml(source.to_spec.to_yaml)
55
+ spec.loaded_from = source.loaded_from
56
+ spec.full_gem_path = source.full_gem_path
57
+ spec
58
+ end
59
+ private_class_method :rebuild
60
+
61
+ # Bundler has no public API for "restrict this Box to these specs", so the
62
+ # two Bundler internals Carton relies on live here. `@replaced_methods`
63
+ # is non-empty once `Bundler.setup` has replaced RubyGems entrypoints.
64
+ # `replace_entrypoints` is the strict half of `Bundler::Runtime#setup`,
65
+ # without resolution, `ENV` exports, or lockfile writes. See
66
+ # docs/internals/upstream.md.
67
+ def bundled?(box)
68
+ box.eval(
69
+ 'defined?(Bundler.rubygems) && ' \
70
+ 'Bundler.rubygems.instance_variable_get(:@replaced_methods)&.any?',
71
+ )
72
+ end
73
+ private_class_method :bundled?
74
+
75
+ def restrict_to(specs)
76
+ require 'bundler'
77
+ Bundler.rubygems.replace_entrypoints(specs)
78
+ end
79
+ private_class_method :restrict_to
80
+
81
+ def owner_of(feature, specs)
82
+ specs
83
+ .select { |spec| shareable?(spec) }
84
+ .filter_map do |spec|
85
+ dir =
86
+ spec.full_require_paths.find do |path|
87
+ feature.start_with?("#{path}/")
88
+ end
89
+ [dir.length, spec] if dir
90
+ end
91
+ .max_by(&:first)
92
+ &.last
93
+ end
94
+ private_class_method :owner_of
95
+
96
+ def visit(spec, loaded_specs, ordered, seen)
97
+ return ordered if seen[spec.name] || !shareable?(spec)
98
+
99
+ seen[spec.name] = true
100
+ spec.runtime_dependencies.each do |dependency|
101
+ dependency_spec = loaded_specs[dependency.name]
102
+ visit(dependency_spec, loaded_specs, ordered, seen) if dependency_spec
103
+ end
104
+ ordered << spec
105
+ end
106
+ private_class_method :visit
107
+
108
+ # Default gems already load in every Box. Bundler is left out too: each
109
+ # Box loads its own copy to apply the restriction, possibly a different
110
+ # version than the caller's, and activating the caller's on top of it
111
+ # raises Gem::LoadError.
112
+ def shareable?(spec)
113
+ !spec.default_gem? && spec.name != 'bundler'
114
+ end
115
+ private_class_method :shareable?
116
+ end
117
+ end
@@ -0,0 +1,70 @@
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
+ def initialize(&loader)
14
+ @loader = loader
15
+ @values = {}
16
+ @gem_versions = {}
17
+ @loading = {}
18
+ @lock = Thread::Mutex.new
19
+ end
20
+
21
+ # Load `target` once per Entrypoint path. Targets without an absolute
22
+ # path are loaded uncached. Nested imports on the owning thread re-enter
23
+ # without waiting on the lock.
24
+ def import(target)
25
+ return @loader.call(target) unless File.absolute_path?(target.feature)
26
+
27
+ key = File.realpath(target.feature)
28
+ return fetch(key, target) if @lock.owned?
29
+
30
+ @lock.synchronize { fetch(key, target) }
31
+ end
32
+
33
+ private
34
+
35
+ def fetch(key, target)
36
+ if @values.key?(key)
37
+ check_gem_versions(key, target)
38
+ return @values[key]
39
+ end
40
+ raise ImportError, "circular import: #{key}" if @loading.key?(key)
41
+
42
+ @loading[key] = true
43
+ begin
44
+ @values[key] = @loader.call(target)
45
+ @gem_versions[key] = gem_versions(target)
46
+ @values[key]
47
+ ensure
48
+ @loading.delete(key)
49
+ end
50
+ end
51
+
52
+ # A cached Carton keeps the gem versions its first importer activated.
53
+ # Raise instead of silently sharing it with an importer that locks a
54
+ # different version of one of those gems.
55
+ def check_gem_versions(key, target)
56
+ cached = @gem_versions.fetch(key)
57
+ gem_versions(target).each do |name, version|
58
+ next if cached.fetch(name, version) == version
59
+
60
+ raise ImportError,
61
+ "#{key} was imported with #{name} #{cached[name]}, " \
62
+ "but this import activates #{name} #{version}"
63
+ end
64
+ end
65
+
66
+ def gem_versions(target)
67
+ (target.gems || []).to_h { |spec| [spec.name, spec.version.to_s] }
68
+ end
69
+ end
70
+ end
@@ -2,24 +2,34 @@
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)
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)
19
+ # Export top-level methods by name plus keyword values from the current
20
+ # carton. `self` is the carton's top-level object, which owns its methods.
21
+ def export(*names, **values)
22
+ file = caller_locations(1, 1).first.absolute_path
23
+ Runtime.export(names, values, owner: self, file:)
16
24
  end
17
25
 
26
+ # Export a single value that `import` returns as is.
18
27
  def export_default(value)
19
- Runtime.export_default(value)
28
+ file = caller_locations(1, 1).first.absolute_path
29
+ Runtime.export_default(value, file:)
20
30
  end
21
31
  end
22
32
  end
23
33
 
24
34
  # Inject only the Kernel patch module into Kernel.
25
- ::Kernel.prepend(Carton::KernelPatch)
35
+ Kernel.prepend(Carton::KernelPatch)
@@ -0,0 +1,120 @@
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
+ Target =
13
+ Struct.new(:feature, :load_path, :gems, :locked_gems, keyword_init: true)
14
+
15
+ EXTENSIONS = ['.rb', ".#{RbConfig::CONFIG['DLEXT']}"].freeze
16
+ private_constant :EXTENSIONS
17
+
18
+ module_function
19
+
20
+ # Resolve `path` like `require_relative` when `base_dir` is given, like
21
+ # `require` with an absolute path, or by searching `load_path` in order.
22
+ # A missing relative or absolute target raises LoadError, as
23
+ # `require_relative` does. A name that matches no Ruby source file, such
24
+ # as a native extension, comes back as the bare `path`. A file inside a
25
+ # Carton raises ImportError: only its Entrypoint can be imported.
26
+ def resolve(path, load_path:, base_dir: nil)
27
+ path = path.to_s
28
+ target =
29
+ if base_dir || File.absolute_path?(path)
30
+ resolve_file(File.expand_path(path, base_dir))
31
+ else
32
+ resolve_name(path, load_path) || Target.new(feature: path)
33
+ end
34
+ reject_inside_carton(path, target)
35
+ target
36
+ end
37
+
38
+ # A Carton is its Entrypoint `x.rb` plus the `x/` directory beside it,
39
+ # the shape of a gem's `lib/` and of a Zeitwerk explicit namespace.
40
+ # Directories without their own `.rb` file, like Zeitwerk implicit
41
+ # namespaces, stay inside the Carton above them. Returns the outermost
42
+ # Entrypoint whose directory holds `file`, or `file` itself.
43
+ def entrypoint_for(file)
44
+ entrypoint = file
45
+ dir = File.dirname(file)
46
+ until dir == File.dirname(dir)
47
+ entrypoint = "#{dir}.rb" if File.file?("#{dir}.rb")
48
+ dir = File.dirname(dir)
49
+ end
50
+ entrypoint
51
+ end
52
+
53
+ def reject_inside_carton(path, target)
54
+ return unless File.absolute_path?(target.feature)
55
+
56
+ entrypoint = entrypoint_for(target.feature)
57
+ return if entrypoint == target.feature
58
+
59
+ raise ImportError, inside_carton_message(path, target, entrypoint)
60
+ end
61
+ private_class_method :reject_inside_carton
62
+
63
+ def inside_carton_message(path, target, entrypoint)
64
+ prefix = "#{target.load_path}/"
65
+ fix =
66
+ if target.load_path && entrypoint.start_with?(prefix)
67
+ name = entrypoint.delete_prefix(prefix).delete_suffix('.rb')
68
+ "#{path} is inside the #{name} Carton: import '#{name}'"
69
+ else
70
+ "#{target.feature} is inside the Carton #{entrypoint}: " \
71
+ 'import that file'
72
+ end
73
+ "#{fix}, or require it from inside that Carton"
74
+ end
75
+ private_class_method :inside_carton_message
76
+
77
+ # A directory laid out like a gem, `billing/lib/billing.rb`, resolves to
78
+ # that Entrypoint with its `lib/` on the Carton's load path, so the
79
+ # Carton can `require 'billing/invoice'` as a gem would.
80
+ def resolve_file(expanded)
81
+ [expanded, "#{expanded}.rb"].each do |candidate|
82
+ return Target.new(feature: candidate) if File.file?(candidate)
83
+ end
84
+ lib = File.join(expanded, 'lib')
85
+ entrypoint = File.join(lib, "#{File.basename(expanded)}.rb")
86
+ if File.file?(entrypoint)
87
+ return Target.new(feature: entrypoint, load_path: lib)
88
+ end
89
+
90
+ raise LoadError, "cannot load such file -- #{expanded}"
91
+ end
92
+ private_class_method :resolve_file
93
+
94
+ # Like Ruby, check each load-path entry in order and try each extension
95
+ # within it, so an earlier native extension shadows a later `.rb`.
96
+ def resolve_name(path, load_path)
97
+ candidates = feature_candidates(path)
98
+
99
+ load_path.each do |entry|
100
+ dir = File.expand_path(entry.to_s)
101
+ candidates.each do |candidate|
102
+ file = File.join(dir, candidate)
103
+ next unless File.file?(file)
104
+ return nil unless file.end_with?('.rb')
105
+
106
+ return Target.new(feature: file, load_path: dir)
107
+ end
108
+ end
109
+ nil
110
+ end
111
+ private_class_method :resolve_name
112
+
113
+ def feature_candidates(path)
114
+ return [path] if EXTENSIONS.include?(File.extname(path))
115
+
116
+ EXTENSIONS.map { |ext| "#{path}#{ext}" }
117
+ end
118
+ private_class_method :feature_candidates
119
+ end
120
+ end
@@ -4,79 +4,100 @@ module Carton
4
4
  module Runtime
5
5
  ENTRYPOINT = File.expand_path('../carton.rb', __dir__)
6
6
  private_constant :ENTRYPOINT
7
+ # Used only when this Box is the Main Box. Carton Boxes reuse the Main
8
+ # Box cache, so every Carton is built by the Main Box loader.
9
+ IMPORT_CACHE = ImportCache.new { |target| load_import(target) }
10
+ private_constant :IMPORT_CACHE
7
11
 
8
12
  module_function
9
13
 
10
- def import(path, base_dir:)
11
- box = build_import_box
12
- target = resolve_import_target(box, path, base_dir:)
13
- box.__send__(:activate_bundle_if_configured) if target == path
14
- box.__send__(:require_in_box, target)
15
- extract_export(box)
14
+ # Load the target feature in a new box and return the exported value, or
15
+ # the box itself if the target does not export anything. Each Entrypoint
16
+ # loads once per process; later imports return the same value.
17
+ # `base_dir` is given for `import_relative`; plain `import` searches the
18
+ # caller Box's `$LOAD_PATH`.
19
+ def import(path, base_dir: nil)
20
+ unless Ruby::Box.enabled?
21
+ raise ImportError, 'Carton needs Ruby::Box: run Ruby with RUBY_BOX=1'
22
+ end
23
+
24
+ caller_box = Ruby::Box.current
25
+ load_path = caller_box.eval('$LOAD_PATH.to_a')
26
+ target = Resolver.resolve(path, load_path:, base_dir:)
27
+ loaded_specs = caller_box.eval('defined?(Gem) ? Gem.loaded_specs : {}')
28
+ target.gems = GemActivation.closure(target.feature, loaded_specs)
29
+ unless target.gems.empty?
30
+ target.locked_gems = GemActivation.locked(caller_box, loaded_specs)
31
+ end
32
+ import_cache.import(target)
16
33
  end
17
34
 
18
- def export(args, kwargs)
19
- if args.any? || kwargs.empty?
35
+ # Add to the current carton's Export Namespace. A capitalized Symbol names
36
+ # a top-level constant, any other Symbol a top-level method; keyword
37
+ # arguments are plain values. Like `private`, a single Symbol is returned
38
+ # so `export def name ...` works.
39
+ def export(names, values, owner:, file:)
40
+ if (names.empty? && values.empty?) ||
41
+ names.any? { |name| !name.is_a?(Symbol) }
20
42
  raise ArgumentError,
21
- 'export takes keyword arguments; use export_default for a single export'
43
+ 'export takes Symbols naming methods or constants, and keyword values'
22
44
  end
23
45
 
24
- current_import_box.__send__(:set_export, kwargs)
25
- end
26
-
27
- def export_default(value)
28
- current_import_box.__send__(:set_export, value)
29
- end
46
+ duplicate = (names + values.keys).tally.find { |_, count| count > 1 }
47
+ if duplicate
48
+ raise ExportError,
49
+ "export name given more than once: #{duplicate.first}"
50
+ end
30
51
 
31
- def build_import_box
32
- parent_box = Ruby::Box.current
33
- box = Carton::Box.new
34
- bundle_gemfile = parent_box.eval("ENV['BUNDLE_GEMFILE']")
35
- box.__send__(
36
- :configure_for_import,
37
- parent_box:,
38
- entrypoint: ENTRYPOINT,
39
- bundle_gemfile:,
52
+ constants, method_names =
53
+ names.partition { |name| name.match?(/\A[A-Z]/) }
54
+ methods = method_names.to_h { |name| [name, owner.method(name)] }
55
+ constants =
56
+ constants.to_h { |name| [name, Object.const_get(name, false)] }
57
+ entrypoint_box(file)&.__send__(
58
+ :declare_export,
59
+ methods,
60
+ values.merge(constants),
40
61
  )
41
- box
62
+ names.first if names.size == 1 && values.empty?
42
63
  end
43
- private_class_method :build_import_box
44
64
 
45
- def extract_export(box)
46
- return box unless box.__send__(:export_set?)
65
+ # Make `value` the current carton's Default Export, which `import`
66
+ # returns as is. It cannot be combined with `export`.
67
+ def export_default(value, file:)
68
+ entrypoint_box(file)&.__send__(:declare_default, value)
69
+ value
70
+ end
47
71
 
48
- value = box.__send__(:export_value)
49
- value.is_a?(Hash) ? Carton::Exports.new(value) : value
72
+ def load_import(target)
73
+ Box
74
+ .new
75
+ .__send__(
76
+ :import_target,
77
+ target,
78
+ carton_entrypoint: ENTRYPOINT,
79
+ import_cache: IMPORT_CACHE,
80
+ ) { |methods, values| Exports.new(methods, values) }
50
81
  end
51
- private_class_method :extract_export
82
+ private_class_method :load_import
52
83
 
53
- def current_import_box
84
+ def import_cache
54
85
  box = Ruby::Box.current
86
+ return IMPORT_CACHE unless box.respond_to?(:import_cache, true)
55
87
 
56
- unless box.respond_to?(:set_export, true)
57
- raise RuntimeError,
58
- 'export/export_default must be called from inside Carton.import/import_relative'
59
- end
60
-
61
- box
88
+ box.__send__(:import_cache)
62
89
  end
63
- private_class_method :current_import_box
64
-
65
- def resolve_import_target(box, path, base_dir:)
66
- expanded = File.expand_path(path, base_dir)
67
- return expanded if File.file?(expanded)
90
+ private_class_method :import_cache
68
91
 
69
- expanded_rb = "#{expanded}.rb"
70
- return expanded_rb if File.file?(expanded_rb)
71
-
72
- resolved = box.__send__(:resolve_feature_path_in_box, path)
73
- return path unless resolved
74
-
75
- type, resolved_path = resolved
76
- return resolved_path if type == :rb && File.file?(resolved_path)
92
+ # The Carton Box whose Entrypoint is `file`, or nil. Outside that file,
93
+ # such as under a plain `require`, `export` is inert, so Carton-aware
94
+ # files and gems still load the ordinary way.
95
+ def entrypoint_box(file)
96
+ box = Ruby::Box.current
97
+ return unless box.respond_to?(:entrypoint?, true)
77
98
 
78
- path
99
+ box if box.__send__(:entrypoint?, file)
79
100
  end
80
- private_class_method :resolve_import_target
101
+ private_class_method :entrypoint_box
81
102
  end
82
103
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end