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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -1
- data/README.md +98 -26
- data/lib/carton/activate_gems.rb +8 -0
- data/lib/carton/box.rb +96 -69
- data/lib/carton/bundler.rb +73 -0
- data/lib/carton/error.rb +23 -0
- data/lib/carton/export_declaration.rb +72 -0
- data/lib/carton/export_methods.rb +7 -2
- data/lib/carton/exports.rb +27 -8
- data/lib/carton/gem_activation.rb +117 -0
- data/lib/carton/import_cache.rb +70 -0
- data/lib/carton/kernel_patch.rb +15 -5
- data/lib/carton/resolver.rb +120 -0
- data/lib/carton/runtime.rb +74 -53
- data/lib/carton/version.rb +1 -1
- data/lib/carton.rb +12 -19
- data/sig/carton.rbs +42 -0
- metadata +17 -31
- data/AGENTS.md +0 -47
- data/DESIGN.md +0 -84
- data/Rakefile +0 -52
- data/TODO.md +0 -33
- data/USAGE.md +0 -173
- data/examples/complex/README.md +0 -33
- data/examples/complex/cartons/adventure/.env +0 -1
- data/examples/complex/cartons/adventure/Gemfile +0 -5
- data/examples/complex/cartons/adventure/Gemfile.lock +0 -17
- data/examples/complex/cartons/adventure/lib/adventure.rb +0 -12
- data/examples/complex/cartons/loot/.env +0 -1
- data/examples/complex/cartons/loot/Gemfile +0 -5
- data/examples/complex/cartons/loot/Gemfile.lock +0 -17
- data/examples/complex/cartons/loot/lib/loot.rb +0 -47
- data/examples/complex/cartons/quest/lib/quest.rb +0 -6
- data/examples/complex/main.rb +0 -37
- data/examples/minimal/README.md +0 -16
- data/examples/minimal/bar.rb +0 -10
- data/examples/minimal/baz.rb +0 -7
- data/examples/minimal/foo.rb +0 -7
- data/examples/minimal/main.rb +0 -17
- 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
|
data/lib/carton/exports.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
|
|
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
|
data/lib/carton/kernel_patch.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/carton/runtime.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
43
|
+
'export takes Symbols naming methods or constants, and keyword values'
|
|
22
44
|
end
|
|
23
45
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
62
|
+
names.first if names.size == 1 && values.empty?
|
|
42
63
|
end
|
|
43
|
-
private_class_method :build_import_box
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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 :
|
|
82
|
+
private_class_method :load_import
|
|
52
83
|
|
|
53
|
-
def
|
|
84
|
+
def import_cache
|
|
54
85
|
box = Ruby::Box.current
|
|
86
|
+
return IMPORT_CACHE unless box.respond_to?(:import_cache, true)
|
|
55
87
|
|
|
56
|
-
|
|
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 :
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
99
|
+
box if box.__send__(:entrypoint?, file)
|
|
79
100
|
end
|
|
80
|
-
private_class_method :
|
|
101
|
+
private_class_method :entrypoint_box
|
|
81
102
|
end
|
|
82
103
|
end
|
data/lib/carton/version.rb
CHANGED