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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -1
- data/README.md +103 -27
- data/lib/carton/activate_gems.rb +8 -0
- data/lib/carton/box.rb +162 -68
- data/lib/carton/bundler.rb +73 -0
- data/lib/carton/error.rb +24 -0
- data/lib/carton/gem_activation.rb +151 -0
- data/lib/carton/import_cache.rb +105 -0
- data/lib/carton/kernel_patch.rb +12 -8
- data/lib/carton/loaded_features.rb +10 -0
- data/lib/carton/namespace.rb +78 -0
- data/lib/carton/{export_methods.rb → namespace_methods.rb} +14 -3
- data/lib/carton/resolver.rb +149 -0
- data/lib/carton/runtime.rb +74 -59
- data/lib/carton/version.rb +1 -1
- data/lib/carton.rb +13 -22
- data/sig/carton.rbs +49 -0
- metadata +20 -34
- 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
- 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
|
data/lib/carton/error.rb
ADDED
|
@@ -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
|
data/lib/carton/kernel_patch.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|