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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3d0c8755a90c3eb40d7ee29a16a174a89f1c678e43ba08275fab4502b298fae
4
- data.tar.gz: 582512d0d84c6e3dfa2b49c9d95b5e77e4fac4a25a45b63b17bf9ed6087d65c1
3
+ metadata.gz: 0a0f0305abe746709cf0b95c29f1f5d94d4ae4d794698205a4aa64fded37780b
4
+ data.tar.gz: 262c64943c2e73831507b95a8dde3e3e8f2ea712f6844e25fa02de178e292bd3
5
5
  SHA512:
6
- metadata.gz: f48e9cdd2c5c64d12f323de0acc65498f7e0c0aa7f230decdf5fae538337e268c07686e9f2b5017b4d38dd1b4c22bb2120bc5c54b5ce6884f59d7b319779b0e5
7
- data.tar.gz: 1470bb044aaacf574535228f1bc9f12a84f0654651c3fbaa52fe52bcde0f215c9262eb0d603c6cc98239c29f4f44854b07534ff608af0bc206e701f2498f1cc0
6
+ metadata.gz: b20c31a54f13d1a25296779c7bfc9b03adb4a6a2df8fb22cf6ebf7fa714f29b0d497ea8ddf7ca5a96e3e83cb76484d5687dfe7a9d9cb7563e5ab7d482d83f054
7
+ data.tar.gz: c0db95029ee083e52ab39b535f03a89bd8cc9ad5b9cf1d0741c90d65fb919e64a1ba369377a62ec17615e6e42a69d5969ec550093adebdba527caf79e12e0fe4
data/CHANGELOG.md CHANGED
@@ -2,9 +2,127 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-10-17
6
+
7
+ Cartons are now gem-sized or domain-sized units: a file plus the directory
8
+ named after it. This release also redesigns exports, shares imports across
9
+ the process, adds gem and Bundler support, and requires Ruby 4.0.7.
10
+
11
+ ### Breaking
12
+
13
+ - A Carton is now a file plus the directory named after it, the shape of a
14
+ gem's `lib/`: `billing.rb` and everything under `billing/`, including
15
+ Zeitwerk implicit-namespace directories. Importing a file inside a Carton,
16
+ such as `import 'billing/invoice'`, raises `Carton::ImportError` naming the
17
+ import to use. It used to load a second copy of the file in its own Box, so
18
+ its classes did not match the Carton's.
19
+ - Carton now requires Ruby 4.0.7 or later, enforced by the gemspec, and
20
+ Bundler support needs Bundler 4.0.23 or later.
21
+ - `export` can be called many times per file, and each call adds to one
22
+ Export Namespace. A capitalized Symbol exports a constant (`export :Money`),
23
+ other Symbols export methods (`export :add`, or `export def add ...`), and
24
+ keywords export plain values. Replace `export add: method(:add)` with
25
+ `export :add`. A missing name raises `NameError`, and a name exported twice
26
+ raises `Carton::ExportError`.
27
+ - Exported methods forward when called (`exports.add(...)`), while `[]`,
28
+ `fetch`, and destructuring return the `Method` object. Keyword values are no
29
+ longer invoked: `export handler: -> {}` makes `exports.handler` return the
30
+ lambda.
31
+ - `export_default` may be called only once per file and cannot be combined
32
+ with `export`; both raise `Carton::ExportError`. Put other names inside the
33
+ default value.
34
+ - Importing a file with no exports (a Bare Carton) returns a `Carton::Exports`
35
+ of the constants and top-level helpers it defines, instead of its
36
+ `Carton::Box`. Importers can no longer `eval`, `require`, or read
37
+ `load_path` inside another Carton. Helpers are callable directly
38
+ (`toolbox.helper`), lookups return their `Method` object
39
+ (`toolbox.fetch(:helper).call`), and constants are read when the import
40
+ finishes. `Carton::Box` is now a private constant.
41
+ - Imports are shared across the process. Each Carton file loads once, and
42
+ every later `import` or `import_relative` of it returns the same value, so
43
+ sibling Cartons share classes and Carton state no longer resets per import.
44
+ A Carton that imports itself raises `Carton::ImportError`.
45
+ - `import 'name'` no longer checks the current directory before `$LOAD_PATH`,
46
+ so results no longer depend on where the app was started. Use an absolute
47
+ path, `import_relative`, or add the directory to `$LOAD_PATH`.
48
+ - `Carton.with_bundle { require 'bundler/setup' }` is replaced by
49
+ `Carton.setup_bundle`. Pass groups as with `Bundler.setup`
50
+ (`Carton.setup_bundle(:default, :test)`) and choose a Gemfile with
51
+ `gemfile:`. Bundler errors raise instead of exiting the process.
52
+
53
+ ### Added
54
+
55
+ - `import_relative` and absolute `import` accept a gem-shaped directory:
56
+ `import_relative 'components/billing'` loads
57
+ `components/billing/lib/billing.rb` with that `lib/` on the Carton's load
58
+ path.
59
+ - `Carton::Error`, the base class of every error Carton raises itself, with
60
+ `Carton::ImportError` (circular imports, gem version mismatches) and
61
+ `Carton::ExportError` (repeated or conflicting exports). They replace plain
62
+ `RuntimeError`s and `ArgumentError`s, and are the same classes in every
63
+ Box. `Carton::GemfileNotFound` is now a `Carton::Error`.
64
+ - `Carton.setup_bundle(*groups, gemfile: nil)` sets up a Carton's own bundle.
65
+ Without `gemfile:` it searches upward from the calling file for `gems.rb`
66
+ or `Gemfile`. It clears any stale `BUNDLE_LOCKFILE` and restores all of
67
+ `ENV` after setup, so a Carton's bundle variables never reach the rest of
68
+ the process. `Carton::GemfileNotFound` is raised when there is no Gemfile.
69
+ - Public RBS signatures in `sig/carton.rbs`.
70
+ - `examples/monorepo`: packages as path gems in one Gemfile, imported by gem
71
+ name.
72
+ - `examples/rails_in_main`: a Rails API app whose domain logic is a
73
+ Zeitwerk-loaded path-gem Carton.
74
+ - `examples/rails_in_cartons`: Rails loaded once in a framework Carton, with
75
+ models and controllers spread across domain Cartons. The Main Box runs only
76
+ Rack.
77
+ - `examples/master_prelude`: an optional, unsupported hack that loads Active
78
+ Support's core extensions into Ruby's Master Box so every Carton inherits
79
+ them.
80
+
81
+ ### Changed
82
+
83
+ - `export` and `export_default` only declare exports from the imported
84
+ Entrypoint. Under plain `require`, or in other files a Carton requires, they
85
+ validate their arguments and do nothing, so Carton-aware files and gems also
86
+ load with `require`.
87
+ - Importing a file from an activated gem activates that gem and its runtime
88
+ dependencies in the Carton's Box at the caller's versions. When the caller
89
+ ran Bundler setup, the Box is also restricted to the caller's bundle: every
90
+ locked gem loads at its locked version, and gems outside the lockfile raise
91
+ `LoadError`.
92
+ - `import`, `import_relative`, `export`, and `export_default` are private
93
+ Kernel methods, like `require`, so `"str".import(...)` raises
94
+ `NoMethodError` and objects no longer `respond_to?` them.
95
+ - `import 'name'` resolves the feature in the caller's Box and carries only
96
+ the matching load-path entry into the imported Box, instead of copying the
97
+ caller's whole `$LOAD_PATH`.
98
+ - `RUBY_BOX=1 bundle exec` works, including Gemfiles that evaluate a gemspec.
99
+ Carton no longer patches RubyGems or Bundler.
100
+
101
+ ### Fixed
102
+
103
+ - Importing without `RUBY_BOX=1` raises `Carton::ImportError` explaining how to
104
+ enable Boxes, instead of `NoMethodError` on `nil`.
105
+ - A missing `import_relative` target raises `LoadError` instead of loading a
106
+ same-named feature from the load path (such as the stdlib `json`), and a
107
+ missing absolute `import` raises `LoadError` instead of `Errno::ENOENT`.
108
+ - `export_default` keeps Hash, `nil`, and `false` values as Default Exports
109
+ instead of treating a Hash as an Export Namespace.
110
+ - A gem Carton imported from another Carton's Box gets its dependencies.
111
+ Activation used to write to the importing Carton's `$LOAD_PATH`, so the
112
+ gem's `require` of a dependency raised `LoadError`.
113
+ - A gem Carton's dependencies load at the caller's versions instead of the
114
+ newest installed ones. A later import whose caller has different versions
115
+ activated raises instead of reusing the cached Carton.
116
+ - Bare Carton lookup only finds constants and helpers the Entrypoint defines.
117
+ Keys are no longer evaluated as Ruby expressions, and `key?` no longer calls
118
+ helpers.
119
+ - Hash-rest patterns (`=> { **rest }`) return every Named Export, or every
120
+ member of a Bare Carton's Public Surface, instead of an empty hash.
121
+
5
122
  ## [0.1.0] - 2026-04-16
6
123
 
7
124
  Initial release.
8
125
 
9
- [Unreleased]: https://github.com/dtcristo/carton/compare/v0.1.0...HEAD
126
+ [Unreleased]: https://github.com/dtcristo/carton/compare/v0.2.0...HEAD
127
+ [0.2.0]: https://github.com/dtcristo/carton/compare/v0.1.0...v0.2.0
10
128
  [0.1.0]: https://github.com/dtcristo/carton/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  <div align="center">
2
+ <img src="images/carton.png" alt="Carton logo" width="200" />
2
3
  <h1>
3
4
  Carton
4
5
  </h1>
5
- <img src="images/carton.png" alt="Carton logo" width="200" />
6
6
  <p>
7
7
  <strong>
8
8
  Easily box your Ruby
@@ -10,56 +10,128 @@
10
10
  </p>
11
11
  </div>
12
12
 
13
- Carton is a thin wrapper around [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html) for safe, ergonomic modularization in Ruby 4+. It gives you imports and exports that feel like ES Modules while still reading like Ruby. Each carton can isolate constants, gems, and monkey patches behind a small public API, so large apps can keep clear boundaries.
13
+ Split a Ruby app into gem-sized units that import and export like modules.
14
+ Each Carton runs in its own
15
+ [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html), so:
16
+
17
+ - two parts of one app can use **different versions of the same gem**;
18
+ - **monkey patches stay** in the Carton that makes them;
19
+ - only what a Carton exports is reachable, so **boundaries hold at runtime**.
20
+
21
+ A Carton is shaped like a gem: `billing.rb` plus the `billing/` directory, with
22
+ no manifest. A gem, a path gem, a directory, or a single file can be one.
23
+
24
+ ## Quick start
25
+
26
+ Carton needs Ruby 4.0.7+ with Boxes enabled (`RUBY_BOX=1`). Install it:
27
+
28
+ ```sh
29
+ gem install carton # or add `gem 'carton'` to your Gemfile
30
+ ```
14
31
 
15
- ## Minimal usage
32
+ A Carton's Entrypoint loads its own files and exports one value with
33
+ `export_default`:
34
+
35
+ ```
36
+ billing.rb # the Entrypoint
37
+ billing/invoice.rb # inside the Carton
38
+ ```
16
39
 
17
40
  ```ruby
18
- # user.rb
19
- class User
20
- def initialize(name) = @name = name
41
+ # billing.rb
42
+ require_relative 'billing/invoice'
43
+
44
+ module Billing
45
+ def self.invoice(total) = Invoice.new(total)
21
46
  end
22
47
 
23
- export_default User
48
+ export_default Billing
24
49
  ```
25
50
 
51
+ Or it exports several names with `export`. Capitalized Symbols name
52
+ constants, other Symbols name methods, and keywords are plain values:
53
+
54
+ ```ruby
55
+ # math.rb
56
+ export def add(a, b) = a + b
57
+ export PI: 3.14159
58
+ ```
59
+
60
+ Import them:
61
+
26
62
  ```ruby
27
63
  # main.rb
28
64
  require 'carton'
29
65
 
30
- User = import_relative 'user'
31
- alice = User.new('Alice')
66
+ Billing = import_relative 'billing'
67
+ Billing.invoice(42)
68
+
69
+ MathTools = import_relative 'math'
70
+ MathTools.add(2, 3) # => 5
71
+ MathTools::PI # => 3.14159
32
72
  ```
33
73
 
34
- Named exports return a namespace-like module:
74
+ ```sh
75
+ RUBY_BOX=1 ruby main.rb
76
+ ```
35
77
 
36
- ```ruby
37
- # math.rb
38
- def add(a, b) = a + b
78
+ `import_relative 'billing/invoice'` raises `Carton::ImportError`: the file is
79
+ inside the `billing` Carton, so only `billing` can be imported. `import 'name'`
80
+ searches `$LOAD_PATH` like `require`, so gems and path gems import by name, and
81
+ a gem-shaped directory (`components/billing/lib/billing.rb`) imports by its
82
+ path. Each Carton loads once per process, and every later import returns the
83
+ same value.
39
84
 
40
- export PI: 3.14159, add: method(:add)
41
- ```
85
+ ## Gems and Bundler
86
+
87
+ A Carton can set up its own bundle, isolated from the app's:
42
88
 
43
89
  ```ruby
44
- MathTools = import_relative 'math'
45
- MathTools::PI
46
- MathTools.add(2, 3)
90
+ # cartons/reports/lib/reports.rb
91
+ Carton.setup_bundle # uses cartons/reports/Gemfile
92
+ require 'bigdecimal'
47
93
  ```
48
94
 
95
+ A gem in the app's bundle imports as a Carton and gets its dependencies at the
96
+ app's locked versions. For a monorepo, make each package a path gem in one
97
+ Gemfile and import it by name; see [examples/monorepo](examples/monorepo/README.md).
98
+
99
+ ## Status
100
+
101
+ Carton is experimental, and so is `Ruby::Box`. It targets Ruby 4.0.7+ and,
102
+ with Bundler, Bundler 4.0.23+. Box is not a security boundary: it isolates
103
+ definitions and load state, not objects. Classes a Carton defines are shared
104
+ with every importer, so reopening one changes it everywhere; see
105
+ [Sharing classes across Cartons](docs/usage.md#sharing-classes-across-cartons).
106
+
49
107
  ## Docs
50
108
 
51
- - [USAGE.md](USAGE.md) - detailed import/export, load path, and Bundler usage
52
- - [DESIGN.md](DESIGN.md) - implementation overview and constraints
53
- - [TODO.md](TODO.md) - future improvements and Bundler upstream ideas
54
- - [examples/minimal/README.md](examples/minimal/README.md) - smallest example
55
- - [examples/complex/README.md](examples/complex/README.md) - multi-carton example
109
+ - [usage.md](docs/usage.md): exports, imports, Bundler, gem Cartons, and testing
110
+ - [design.md](docs/design.md): how an import runs
111
+ - [todo.md](docs/todo.md): planned work and open questions
112
+ - [internals/](docs/internals/): how `Ruby::Box`, RubyGems, and Bundler work, and upstream plans
113
+ - [CONTEXT.md](CONTEXT.md): the glossary
114
+
115
+ Examples, each runnable with `RUBY_BOX=1 ruby examples/<name>/main.rb`:
116
+
117
+ | Example | Shows |
118
+ | --- | --- |
119
+ | [minimal](examples/minimal/README.md) | A Carton with its own directory, a single-file Carton, and the error for importing a file inside a Carton |
120
+ | [gems](examples/gems/README.md) | Two Cartons using different `bigdecimal` versions without Bundler |
121
+ | [bundler](examples/bundler/README.md) | Cartons with their own Bundler setup, plus a path gem |
122
+ | [monorepo](examples/monorepo/README.md) | Packages as path gems in one Gemfile, imported by gem name |
123
+ | [rails_in_main](examples/rails_in_main/README.md) | Rails in the Main Box, domain logic in a Carton |
124
+ | [rails_in_cartons](examples/rails_in_cartons/README.md) | Rails loaded once in a Carton, models and controllers in domain Cartons |
125
+ | [master_prelude](examples/master_prelude/README.md) | An unsupported hack that shares Active Support with every Carton |
56
126
 
57
127
  ## Development
58
128
 
59
129
  ```sh
60
- bundle install # Install development deps
61
- RUBY_BOX=1 bundle exec rake # Run all tests and examples
62
- bundle exec rake format # Format code
130
+ bundle install
131
+ bundle exec rake # tests, RuboCop, and every example
132
+ bundle exec rake test # tests only
133
+ bundle exec rake example:monorepo # one example
134
+ bundle exec rake format # format with Syntax Tree
63
135
  ```
64
136
 
65
137
  ## License
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Required inside a Carton Box by `Carton::Box#activate_gems`. `$LOAD_PATH`
4
+ # resolves to the Box of the file being loaded, so activating from a file
5
+ # required here updates this Box even during a nested Import.
6
+ Carton.const_get(:GemActivation).activate(
7
+ *Ruby::Box.current.__send__(:pending_gems),
8
+ )
data/lib/carton/box.rb CHANGED
@@ -2,114 +2,141 @@
2
2
 
3
3
  module Carton
4
4
  class Box < Ruby::Box
5
- include ExportMethods
6
-
7
- UNSET_EXPORT = Object.new.freeze
8
- private_constant :UNSET_EXPORT
5
+ ACTIVATE_GEMS = File.expand_path('activate_gems.rb', __dir__)
6
+ private_constant :ACTIVATE_GEMS
7
+ ERRORS = %i[Error ImportError ExportError GemfileNotFound].freeze
8
+ private_constant :ERRORS
9
9
 
10
10
  def initialize
11
11
  super
12
- reset_export
12
+ reset_export_declaration
13
13
  end
14
14
 
15
15
  private
16
16
 
17
- def configure_for_import(parent_box:, entrypoint:, bundle_gemfile:)
18
- inherit_load_path(parent_box)
19
- reset_export
17
+ # The whole Import lifecycle, in order: boot Carton in this Box, load the
18
+ # target, then build its public surface with `build`. Runtime calls only
19
+ # this, so the ordering lives here.
20
+ def import_target(target, carton_entrypoint:, import_cache:, &build)
21
+ configure_for_import(entrypoint: carton_entrypoint, import_cache:)
22
+ load_import(
23
+ target.feature,
24
+ load_path: target.load_path,
25
+ gems: target.gems || [],
26
+ locked_gems: target.locked_gems,
27
+ )
28
+ export_public_surface(&build)
29
+ end
30
+
31
+ # Fresh boxes copy Master, so they start without the caller's gems. Load
32
+ # Carton's own entrypoint so the imported file gets `import`,
33
+ # `import_relative`, and `export`. Nested imports reuse the caller's
34
+ # process-wide import cache.
35
+ def configure_for_import(entrypoint:, import_cache:)
36
+ @import_cache = import_cache
37
+ reset_export_declaration
38
+ add_import_load_path(File.dirname(entrypoint))
20
39
  require_in_box(entrypoint)
21
- set_bundle_gemfile(bundle_gemfile)
40
+ share_error_classes
41
+ snapshot_import_support
22
42
  self
23
43
  end
24
44
 
25
- def require_in_box(feature)
26
- eval("require #{feature.inspect}")
27
- end
45
+ # Activate the caller's gems for the target, then load it inside the box.
46
+ def load_import(feature, load_path: nil, gems: [], locked_gems: nil)
47
+ activate_gems(gems, locked_gems) unless gems.empty?
48
+ add_import_load_path(load_path) if load_path
28
49
 
29
- def resolve_feature_path_in_box(feature)
30
- eval("$LOAD_PATH.resolve_feature_path(#{feature.inspect})")
50
+ @entrypoint = real_path(feature)
51
+ result = require_in_box(feature)
52
+ discover_public_entries
53
+ result
31
54
  end
32
55
 
33
- def activate_bundle_if_configured
34
- gemfile = ENV['BUNDLE_GEMFILE']
35
- return unless gemfile && File.file?(gemfile)
56
+ attr_reader :import_cache, :pending_gems
36
57
 
37
- require_in_box('bundler/setup')
58
+ def require_in_box(feature)
59
+ eval("require #{feature.inspect}")
38
60
  end
39
61
 
40
- def set_export(value)
41
- if export_set?
42
- raise RuntimeError, 'only one export is allowed per imported file'
62
+ # Box methods run in the Box that defined them, so this Box's own copy of
63
+ # Carton activates gems in this Box's RubyGems. `$LOAD_PATH` resolves to
64
+ # the Box of the file being loaded, which during a nested Import is the
65
+ # importing Carton, so activation runs from a file required here.
66
+ def activate_gems(gems, locked_gems)
67
+ @pending_gems = [gems, locked_gems]
68
+ require_in_box(ACTIVATE_GEMS)
69
+ ensure
70
+ @pending_gems = nil
71
+ end
72
+
73
+ # This Box just loaded its own copy of Carton, whose error classes are new
74
+ # objects that `rescue Carton::Error` in another Box would never match.
75
+ # Point them at the importer's classes, so one rescue catches errors
76
+ # raised in any Carton.
77
+ def share_error_classes
78
+ carton = const_get(:Carton, false)
79
+ ERRORS.each do |name|
80
+ carton.__send__(:remove_const, name)
81
+ carton.const_set(name, Carton.const_get(name, false))
43
82
  end
44
-
45
- @export = value
46
- end
47
-
48
- def export_set?
49
- !@export.equal?(UNSET_EXPORT)
50
83
  end
51
84
 
52
- def export_value
53
- @export
85
+ def add_import_load_path(path)
86
+ load_path.unshift(path) unless load_path.include?(path)
54
87
  end
55
88
 
56
- def reset_export
57
- @export = UNSET_EXPORT
89
+ # Only the imported Entrypoint declares this Carton's exports. Other files
90
+ # it requires may call `export` for their own imports; here it is inert.
91
+ def entrypoint?(file)
92
+ !@entrypoint.nil? && real_path(file) == @entrypoint
58
93
  end
59
94
 
60
- def set_bundle_gemfile(path)
61
- return unless path
95
+ def real_path(path)
96
+ return unless path && File.absolute_path?(path) && File.file?(path)
62
97
 
63
- eval("ENV['BUNDLE_GEMFILE'] = #{path.inspect}")
98
+ File.realpath(path)
64
99
  end
65
100
 
66
- def inherit_load_path(source_box)
67
- return self unless source_box.respond_to?(:load_path)
68
-
69
- source_box.load_path.each do |path|
70
- next unless inherit_path?(path)
71
-
72
- load_path << path unless load_path.include?(path)
73
- end
101
+ def declare_export(methods, values)
102
+ @export_declaration = @export_declaration.add(methods, values)
103
+ end
74
104
 
75
- self
105
+ def declare_default(value)
106
+ @export_declaration = @export_declaration.default(value)
76
107
  end
77
108
 
78
- def inherit_path?(path)
79
- expanded = File.expand_path(path)
80
- !gem_path?(expanded)
109
+ # A Bare Carton's surface is the constants and top-level helpers its
110
+ # Entrypoint defined, so importers never receive the Box itself.
111
+ def export_public_surface(&)
112
+ @export_declaration.public_surface(bare: method(:bare_surface), &)
81
113
  end
82
114
 
83
- def gem_path?(path)
84
- gem_roots = Gem.path.map { |root| File.expand_path(root) }
85
- gem_roots.any? { |root| path == root || path.start_with?("#{root}/") } ||
86
- path.include?('/vendor/bundle/') || path.include?('/bundler/gems/')
115
+ def bare_surface
116
+ constants =
117
+ @public_constant_names.to_h { |name| [name, const_get(name, false)] }
118
+ [@public_method_entries, constants]
87
119
  end
88
120
 
89
- def lookup_entry(key)
90
- name = key.to_s
121
+ def reset_export_declaration
122
+ @export_declaration = ExportDeclaration.absent
123
+ end
91
124
 
92
- if name.match?(/\A[A-Z]/)
93
- lookup_constant_entry(name)
94
- else
95
- lookup_method_entry(name)
96
- end
125
+ def snapshot_import_support
126
+ @import_constant_names = constants(false).map(&:to_s)
127
+ @import_method_names = top_level_methods.keys
97
128
  end
98
129
 
99
- def lookup_constant_entry(name)
100
- [true, const_get(name)]
101
- rescue NameError
102
- [false, nil]
130
+ def discover_public_entries
131
+ @public_constant_names =
132
+ constants(false).map(&:to_s) - @import_constant_names
133
+ @public_method_entries = top_level_methods.except(*@import_method_names)
103
134
  end
104
135
 
105
- def lookup_method_entry(name)
106
- [true, eval(name)]
107
- rescue NameError, NoMethodError
108
- begin
109
- [true, __send__(name)]
110
- rescue NoMethodError
111
- [false, nil]
112
- end
136
+ def top_level_methods
137
+ eval(
138
+ 'Object.private_instance_methods(false).to_h { |name| [name.to_s, method(name)] }',
139
+ )
113
140
  end
114
141
  end
115
142
  end
@@ -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,23 @@
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 Declaration that breaks the rules: a name exported twice,
16
+ # `export_default` called twice, or both forms in one Entrypoint.
17
+ class ExportError < Error
18
+ end
19
+
20
+ # Raised when Carton cannot find the bundle file for `setup_bundle`.
21
+ class GemfileNotFound < Error
22
+ end
23
+ end