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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a0f0305abe746709cf0b95c29f1f5d94d4ae4d794698205a4aa64fded37780b
|
|
4
|
+
data.tar.gz: 262c64943c2e73831507b95a8dde3e3e8f2ea712f6844e25fa02de178e292bd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
74
|
+
```sh
|
|
75
|
+
RUBY_BOX=1 ruby main.rb
|
|
76
|
+
```
|
|
35
77
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
- [
|
|
52
|
-
- [
|
|
53
|
-
- [
|
|
54
|
-
- [
|
|
55
|
-
- [
|
|
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
|
|
61
|
-
|
|
62
|
-
bundle exec rake
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
private_constant :
|
|
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
|
-
|
|
12
|
+
reset_export_declaration
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
40
|
+
share_error_classes
|
|
41
|
+
snapshot_import_support
|
|
22
42
|
self
|
|
23
43
|
end
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
50
|
+
@entrypoint = real_path(feature)
|
|
51
|
+
result = require_in_box(feature)
|
|
52
|
+
discover_public_entries
|
|
53
|
+
result
|
|
31
54
|
end
|
|
32
55
|
|
|
33
|
-
|
|
34
|
-
gemfile = ENV['BUNDLE_GEMFILE']
|
|
35
|
-
return unless gemfile && File.file?(gemfile)
|
|
56
|
+
attr_reader :import_cache, :pending_gems
|
|
36
57
|
|
|
37
|
-
|
|
58
|
+
def require_in_box(feature)
|
|
59
|
+
eval("require #{feature.inspect}")
|
|
38
60
|
end
|
|
39
61
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
53
|
-
|
|
85
|
+
def add_import_load_path(path)
|
|
86
|
+
load_path.unshift(path) unless load_path.include?(path)
|
|
54
87
|
end
|
|
55
88
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
return unless path
|
|
95
|
+
def real_path(path)
|
|
96
|
+
return unless path && File.absolute_path?(path) && File.file?(path)
|
|
62
97
|
|
|
63
|
-
|
|
98
|
+
File.realpath(path)
|
|
64
99
|
end
|
|
65
100
|
|
|
66
|
-
def
|
|
67
|
-
|
|
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
|
-
|
|
105
|
+
def declare_default(value)
|
|
106
|
+
@export_declaration = @export_declaration.default(value)
|
|
76
107
|
end
|
|
77
108
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
121
|
+
def reset_export_declaration
|
|
122
|
+
@export_declaration = ExportDeclaration.absent
|
|
123
|
+
end
|
|
91
124
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
data/lib/carton/error.rb
ADDED
|
@@ -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
|