octorail 1.0.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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/lib/octorail/compiler/code.rb +55 -0
- data/lib/octorail/compiler/nested_cycles.rb +34 -0
- data/lib/octorail/compiler/nested_emitter.rb +33 -0
- data/lib/octorail/compiler/recovery_emitter.rb +70 -0
- data/lib/octorail/compiler/scopes.rb +113 -0
- data/lib/octorail/compiler/signature.rb +112 -0
- data/lib/octorail/compiler/step_emitter.rb +35 -0
- data/lib/octorail/compiler/switch_emitter.rb +52 -0
- data/lib/octorail/compiler/tracing.rb +56 -0
- data/lib/octorail/compiler/wrap_emitter.rb +74 -0
- data/lib/octorail/compiler.rb +160 -0
- data/lib/octorail/config.rb +35 -0
- data/lib/octorail/dsl/nested.rb +40 -0
- data/lib/octorail/dsl/outputs.rb +37 -0
- data/lib/octorail/dsl/settings.rb +34 -0
- data/lib/octorail/dsl/steps.rb +111 -0
- data/lib/octorail/dsl/switches.rb +71 -0
- data/lib/octorail/dsl/wraps.rb +50 -0
- data/lib/octorail/dsl.rb +72 -0
- data/lib/octorail/errors.rb +40 -0
- data/lib/octorail/invalidation.rb +49 -0
- data/lib/octorail/nodes/nested.rb +18 -0
- data/lib/octorail/nodes/step.rb +20 -0
- data/lib/octorail/nodes/switch.rb +25 -0
- data/lib/octorail/nodes/wrap.rb +26 -0
- data/lib/octorail/nodes.rb +21 -0
- data/lib/octorail/operation.rb +145 -0
- data/lib/octorail/result.rb +135 -0
- data/lib/octorail/runtime.rb +52 -0
- data/lib/octorail/trace/rendering.rb +110 -0
- data/lib/octorail/trace.rb +182 -0
- data/lib/octorail/version.rb +5 -0
- data/lib/octorail.rb +21 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3a292913a9530a1fa803b554fc6d2263532d083cfa7cb82e73a467368861e4a9
|
|
4
|
+
data.tar.gz: 7fb841e132dca2d0ac965070ce67a42c73685c58c416c4a891fd8e26789622f5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6965c6a4f8a96ad6883b0e6b409fb609ea27619331dd6d4112e5f115fc10b454641b5709b30cee91311001ef3534c1aa7e34f79e52c9e7e96908dd0ba8b1f72d
|
|
7
|
+
data.tar.gz: 2cbf04c2cdd038721f7959df8ae112a28e89a080b9f40a908323833785c0964880bb445cf3a35117dca1868631d3ee0b7c4f9952808e02f7ab5936c33bfe128f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.0.0] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `step`, `pass` and `failure` steps as instance methods, compiled into one plain
|
|
14
|
+
Ruby method on first call, or all at once with `Octorail.compile_all!`.
|
|
15
|
+
- Step signatures `(ctx)` or `(ctx, key:, ...)`: keyword arguments are read from
|
|
16
|
+
ctx, and a missing key raises `Octorail::MissingKeyError` before the step runs.
|
|
17
|
+
- `error:` on steps; `result.error` and `result.failed_step` on failure.
|
|
18
|
+
- `output` declarations: a result exposes only declared keys.
|
|
19
|
+
- `fail_fast: true` on `step` and `failure` to end with the `:fail_fast` terminus.
|
|
20
|
+
- `failure ..., recover: true` to return to the success track.
|
|
21
|
+
- `wrap` to run a group of steps inside a block (transaction, lock, ...).
|
|
22
|
+
- `nested` to call another operation with the shared ctx, and `nested_call(ctx)`
|
|
23
|
+
as its entry point.
|
|
24
|
+
- `switch` / `on` to pick a branch by a decider method's Symbol;
|
|
25
|
+
an unknown value raises `Octorail::UnknownBranchError`.
|
|
26
|
+
- `abstract!` base operations that hold shared methods and settings, but no steps.
|
|
27
|
+
- `Octorail.configure` and per-operation `configure`.
|
|
28
|
+
- Tracing at `:path` or `:profile` level, `call_traced`, `result.trace`, and
|
|
29
|
+
`exception.octorail_trace`, rendered as a tree (`to_s`) or a hash (`to_h`).
|
|
30
|
+
- `to_ruby` to print the generated source.
|
|
31
|
+
- Compile-time errors (`Octorail::CompileError` and friends, all `< ScriptError`)
|
|
32
|
+
that point at the declaration line.
|
|
33
|
+
|
|
34
|
+
[Unreleased]: https://github.com/Octorail-team/octorail/compare/v1.0.0...HEAD
|
|
35
|
+
[1.0.0]: https://github.com/Octorail-team/octorail/releases/tag/v1.0.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmytro Bereza
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Octorail-team/octorail/main/.github/assets/banner.png" alt="Octorail: railway-oriented operations for Ruby, compiled to plain Ruby" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Octorail
|
|
6
|
+
|
|
7
|
+
[](https://github.com/Octorail-team/octorail/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/Octorail-team/octorail/actions/workflows/ci.yml)
|
|
9
|
+
[](https://www.ruby-lang.org)
|
|
10
|
+
[](https://github.com/Octorail-team/octorail/blob/main/docs/performance.md)
|
|
11
|
+
[](https://github.com/Octorail-team/octorail/blob/main/octorail.gemspec)
|
|
12
|
+
[](https://github.com/Octorail-team/octorail/actions/workflows/lint.yml)
|
|
13
|
+
[](https://github.com/Octorail-team/octorail/blob/main/LICENSE.txt)
|
|
14
|
+
|
|
15
|
+
Railway-oriented operations for Ruby, in the spirit of Trailblazer, compiled to
|
|
16
|
+
plain Ruby. You declare steps; on first use Octorail turns them into one
|
|
17
|
+
ordinary method of `if`s and `return`s, so a call costs about what
|
|
18
|
+
hand-written code costs and allocates no more.
|
|
19
|
+
|
|
20
|
+
It is deliberately strict: steps are instance methods, ctx is a Hash, the
|
|
21
|
+
railway is linear, and anything ambiguous is an error at load time with the
|
|
22
|
+
line that caused it.
|
|
23
|
+
|
|
24
|
+
Requires Ruby >= 3.3. No dependencies.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
gem "octorail"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class Orders::Checkout < Octorail::Operation
|
|
34
|
+
output :order
|
|
35
|
+
|
|
36
|
+
nested Orders::Validate
|
|
37
|
+
wrap :transaction do
|
|
38
|
+
step :create_order
|
|
39
|
+
nested Payments::Charge
|
|
40
|
+
end
|
|
41
|
+
switch :delivery_for do
|
|
42
|
+
on(:pickup)
|
|
43
|
+
on(:courier) { step :book_courier }
|
|
44
|
+
end
|
|
45
|
+
pass :send_receipt
|
|
46
|
+
failure :log_failure
|
|
47
|
+
|
|
48
|
+
def transaction(_ctx, &) = ActiveRecord::Base.transaction(requires_new: true) { yield || raise(ActiveRecord::Rollback) }
|
|
49
|
+
def create_order(ctx, user:, total:) = ctx[:order] = user.orders.create!(total:)
|
|
50
|
+
def delivery_for(_ctx, delivery: :courier) = delivery
|
|
51
|
+
def book_courier(ctx, order:, address:) = Couriers.book(order, address)
|
|
52
|
+
def send_receipt(_ctx, order:) = ReceiptMailer.with(order:).deliver_later
|
|
53
|
+
def log_failure(_ctx, error:, failed_step:) = Rails.logger.warn("checkout: #{error} at #{failed_step}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result = Orders::Checkout.call(user:, total: 42, address: "Kyiv")
|
|
57
|
+
result.success? # => false
|
|
58
|
+
result.error # => :declined (from inside Payments::Charge)
|
|
59
|
+
result.failed_step # => :charge
|
|
60
|
+
|
|
61
|
+
case result
|
|
62
|
+
in { success: true, order: } then redirect_to order
|
|
63
|
+
in { error: :declined } then render :card_declined
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
And when you need to know why:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
Orders::Checkout 4.874 ms
|
|
71
|
+
├── ✓ nested Orders::Validate 0.636 ms
|
|
72
|
+
│ └── ✓ check 0.002 ms
|
|
73
|
+
├── wrap transaction → false 4.176 ms
|
|
74
|
+
│ ├── ✓ create_order 0.001 ms
|
|
75
|
+
│ └── ✗ nested Payments::Charge 4.171 ms
|
|
76
|
+
│ ├── ✓ authorize 0.001 ms
|
|
77
|
+
│ └── ✗ charge 2.993 ms
|
|
78
|
+
├── · failure log_failure 0.001 ms
|
|
79
|
+
└── failure error=:declined failed_step=:charge
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## What you get
|
|
83
|
+
|
|
84
|
+
- `step` / `pass` / `failure`, with keyword arguments read from ctx and a
|
|
85
|
+
clear error when a required key is missing.
|
|
86
|
+
- `wrap` for transactions, locks, retries; `nested` to compose operations;
|
|
87
|
+
`switch` to branch on a value.
|
|
88
|
+
- `fail_fast:` and `failure ..., recover: true` to leave or rejoin the success track.
|
|
89
|
+
- Results with `error`, `failed_step`, declared outputs and pattern matching.
|
|
90
|
+
- Tracing on demand (`call_traced`), per operation or globally, attached to
|
|
91
|
+
results and to exceptions.
|
|
92
|
+
- Errors at load time that point at the declaration line.
|
|
93
|
+
|
|
94
|
+
## Speed
|
|
95
|
+
|
|
96
|
+
Ten trivial steps, Ruby 4.0 with YJIT: Octorail 416 ns, a hand-written PORO
|
|
97
|
+
425 ns, Yaso 897 ns, Decouplio 1.1 µs, Trailblazer 25.2 µs. Allocations equal
|
|
98
|
+
the PORO's. Averaged over 36 benchmarks (3 Rubies, with and without YJIT):
|
|
99
|
+
1.7× faster than Yaso, 1.9× than Decouplio, 38× than Trailblazer. Full tables
|
|
100
|
+
and caveats in [performance](https://github.com/Octorail-team/octorail/blob/main/docs/performance.md).
|
|
101
|
+
|
|
102
|
+
## Documentation
|
|
103
|
+
|
|
104
|
+
- [Guide](https://github.com/Octorail-team/octorail/blob/main/docs/guide.md): one operation built end to end
|
|
105
|
+
- [Steps](https://github.com/Octorail-team/octorail/blob/main/docs/steps.md) · [Result](https://github.com/Octorail-team/octorail/blob/main/docs/result.md) ·
|
|
106
|
+
[Fail fast and recover](https://github.com/Octorail-team/octorail/blob/main/docs/fail-fast-and-recover.md)
|
|
107
|
+
- [Wrap](https://github.com/Octorail-team/octorail/blob/main/docs/wrap.md) · [Nested](https://github.com/Octorail-team/octorail/blob/main/docs/nested.md) · [Switch](https://github.com/Octorail-team/octorail/blob/main/docs/switch.md) ·
|
|
108
|
+
[Inheritance and macros](https://github.com/Octorail-team/octorail/blob/main/docs/inheritance.md)
|
|
109
|
+
- [Compilation](https://github.com/Octorail-team/octorail/blob/main/docs/compilation.md) · [Tracing](https://github.com/Octorail-team/octorail/blob/main/docs/tracing.md) ·
|
|
110
|
+
[Configuration](https://github.com/Octorail-team/octorail/blob/main/docs/configuration.md) · [Errors](https://github.com/Octorail-team/octorail/blob/main/docs/errors.md)
|
|
111
|
+
- [Testing](https://github.com/Octorail-team/octorail/blob/main/docs/testing.md) · [Performance](https://github.com/Octorail-team/octorail/blob/main/docs/performance.md)
|
|
112
|
+
|
|
113
|
+
Each page ends with **Limits**: what the gem forbids around that feature, why,
|
|
114
|
+
and what to do instead.
|
|
115
|
+
|
|
116
|
+
## Rails
|
|
117
|
+
|
|
118
|
+
Octorail itself doesn't depend on Rails. To compile every operation at boot, so
|
|
119
|
+
a broken one fails the deploy instead of the first request, add an initializer:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# config/initializers/octorail.rb
|
|
123
|
+
Rails.application.config.after_initialize do
|
|
124
|
+
Octorail.compile_all! if Rails.configuration.eager_load
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
Everything runs in Docker; no local Ruby needed.
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
make build # build images for Ruby 3.3 / 3.4 / 4.0
|
|
134
|
+
make test # rspec on Ruby 3.3
|
|
135
|
+
make test RUBY=ruby40 # rspec on a specific Ruby
|
|
136
|
+
make test-all # rspec on every supported Ruby
|
|
137
|
+
make lint # rubocop
|
|
138
|
+
make bench RUBY=ruby34 # benchmarks against Yaso, Decouplio, Trailblazer
|
|
139
|
+
make console # IRB with the gem loaded
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT, see [LICENSE.txt](https://github.com/Octorail-team/octorail/blob/main/LICENSE.txt).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Collects generated source line by line and keeps track of indentation.
|
|
6
|
+
class Code
|
|
7
|
+
def initialize
|
|
8
|
+
@lines = []
|
|
9
|
+
@depth = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def line(text)
|
|
13
|
+
@lines << "#{" " * @depth}#{text}"
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def indented
|
|
18
|
+
@depth += 1
|
|
19
|
+
yield
|
|
20
|
+
self
|
|
21
|
+
ensure
|
|
22
|
+
@depth -= 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# For `rescue` / `else` lines that sit one level out from the body.
|
|
26
|
+
def dedented
|
|
27
|
+
@depth -= 1
|
|
28
|
+
yield
|
|
29
|
+
self
|
|
30
|
+
ensure
|
|
31
|
+
@depth += 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# header ... end
|
|
35
|
+
def block(header, &)
|
|
36
|
+
line(header)
|
|
37
|
+
indented(&)
|
|
38
|
+
line("end")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# if condition ... else ... end
|
|
42
|
+
def if_else(condition, on_true, on_false)
|
|
43
|
+
line("if #{condition}")
|
|
44
|
+
indented(&on_true)
|
|
45
|
+
line("else")
|
|
46
|
+
indented(&on_false)
|
|
47
|
+
line("end")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
"#{@lines.join("\n")}\n"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# A nested operation that leads back to one on the current path would recurse until
|
|
6
|
+
# SystemStackError; this walks the nested graph (through wraps, branches and inherited steps).
|
|
7
|
+
class NestedCycles
|
|
8
|
+
def initialize(operation)
|
|
9
|
+
@operation = operation
|
|
10
|
+
@done = Set.new # operations already known to be cycle-free
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def check! = walk(@operation, [@operation])
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def walk(operation, path)
|
|
18
|
+
operation.all_steps.grep(Nodes::Nested).each do |node|
|
|
19
|
+
target = node.operation
|
|
20
|
+
next if @done.include?(target)
|
|
21
|
+
|
|
22
|
+
cycle!(path.drop(path.index(target)) << target, node) if path.include?(target)
|
|
23
|
+
walk(target, path + [target])
|
|
24
|
+
end
|
|
25
|
+
@done << operation
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cycle!(cycle, node)
|
|
29
|
+
raise CompileError, "#{@operation}: nested operations form a cycle: #{cycle.join(" → ")}" \
|
|
30
|
+
"#{Octorail.declared_at(node)}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Emits a nested operation: called with the same ctx, its Result decides like a step's return value.
|
|
6
|
+
#
|
|
7
|
+
# __n1 = ::Billing::Charge.nested_call(ctx)
|
|
8
|
+
# unless __n1.success?
|
|
9
|
+
# log_failure(ctx)
|
|
10
|
+
# return Result.new(:failure, ctx, __n1.error, __n1.failed_step)
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The inner error / failed_step show through (error: replaces the error); the inner terminus
|
|
14
|
+
# doesn't: only the node's own fail_fast skips the outer failure steps.
|
|
15
|
+
class NestedEmitter
|
|
16
|
+
def initialize(compiler)
|
|
17
|
+
@compiler = compiler
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def emit(code, nested, index, scope)
|
|
21
|
+
id = @compiler.next_id
|
|
22
|
+
result = "__n#{id}"
|
|
23
|
+
trace = @compiler.start_trace(code, scope, :nested, @compiler.trace_label(nested.operation), id)
|
|
24
|
+
code.line("#{result} = #{@compiler.constant_for(nested.operation)}.nested_call(ctx#{", #{trace}" if trace})")
|
|
25
|
+
@compiler.done_trace(code, scope, trace, "#{result}.success?")
|
|
26
|
+
error = nested.error ? nested.error.inspect : "#{result}.error"
|
|
27
|
+
code.block("unless #{result}.success?") do
|
|
28
|
+
scope.emit_failure(code, index, error, "#{result}.failed_step", fail_fast: nested.fail_fast)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Emits a list that has a `failure ..., recover: true` step (see RecoveryScope):
|
|
6
|
+
#
|
|
7
|
+
# __r1_t = __r1_e = __r1_s = nil
|
|
8
|
+
# unless charge_card(ctx)
|
|
9
|
+
# __r1_t = :failure; __r1_e = :declined; __r1_s = :charge_card
|
|
10
|
+
# end
|
|
11
|
+
# if __r1_t == :failure
|
|
12
|
+
# if charge_wallet(ctx, failed_step: __r1_s)
|
|
13
|
+
# __r1_t = nil
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
# unless __r1_t
|
|
17
|
+
# ...next steps...
|
|
18
|
+
# end
|
|
19
|
+
# if __r1_t
|
|
20
|
+
# ...leave through the list's own scope (return / next / outer failure track)...
|
|
21
|
+
# end
|
|
22
|
+
class RecoveryEmitter
|
|
23
|
+
def self.needed?(nodes) = nodes.any? { |node| node.is_a?(Nodes::Step) && node.recover }
|
|
24
|
+
|
|
25
|
+
def initialize(compiler)
|
|
26
|
+
@compiler = compiler
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def emit(code, base)
|
|
30
|
+
scope = RecoveryScope.new(base, @compiler, @compiler.next_id)
|
|
31
|
+
flag = scope.var(:t)
|
|
32
|
+
code.line("#{flag} = #{scope.var(:e)} = #{scope.var(:s)} = nil")
|
|
33
|
+
base.nodes.each_with_index do |node, index|
|
|
34
|
+
next emit_failure_step(code, scope, node) if @compiler.failure_step?(node)
|
|
35
|
+
|
|
36
|
+
@compiler.emit_node(code, node, index, scope, unless_flag: flag)
|
|
37
|
+
end
|
|
38
|
+
emit_exit(code, scope)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Runs only on the failure track; a recovering one that returns truthy clears the flag;
|
|
44
|
+
# a fail_fast one (that didn't recover) cuts the track.
|
|
45
|
+
def emit_failure_step(code, scope, failure)
|
|
46
|
+
flag = scope.var(:t)
|
|
47
|
+
code.block("if #{flag} == :failure") do
|
|
48
|
+
next emit_plain(code, scope, failure) unless failure.recover
|
|
49
|
+
|
|
50
|
+
recovered = @compiler.condition(code, failure, scope, type: :recover,
|
|
51
|
+
error: scope.var(:e), failed_step: scope.var(:s))
|
|
52
|
+
next code.block("if #{recovered}") { code.line("#{flag} = nil") } unless failure.fail_fast
|
|
53
|
+
|
|
54
|
+
code.if_else(recovered, -> { code.line("#{flag} = nil") }, -> { code.line("#{flag} = :fail_fast") })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def emit_plain(code, scope, failure)
|
|
59
|
+
@compiler.emit_failure_step(code, failure, scope.var(:e), scope.var(:s), scope)
|
|
60
|
+
code.line("#{scope.var(:t)} = :fail_fast") if failure.fail_fast
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def emit_exit(code, scope)
|
|
64
|
+
flag = scope.var(:t)
|
|
65
|
+
exit = ->(terminus) { -> { scope.base.emit_exit(code, terminus, scope.var(:e), scope.var(:s)) } }
|
|
66
|
+
code.block("if #{flag}") { code.if_else("#{flag} == :fail_fast", exit.call(:fail_fast), exit.call(:failure)) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Where a failure leads from a list of nodes. Emitters ask the scope instead of hard-coding
|
|
6
|
+
# `return`, so the same step code works at the top level, in a wrap, in a branch and around recovery.
|
|
7
|
+
class Scope
|
|
8
|
+
# trace_var: in the traced variant, the local holding the Trace::Node the nodes record into.
|
|
9
|
+
attr_reader :nodes, :trace_var
|
|
10
|
+
|
|
11
|
+
def initialize(nodes, compiler, trace_var: nil)
|
|
12
|
+
@nodes = nodes
|
|
13
|
+
@compiler = compiler
|
|
14
|
+
@trace_var = trace_var
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Failure steps are positional: they handle failures of the nodes declared before them.
|
|
18
|
+
def failure_steps_after(index)
|
|
19
|
+
@nodes.drop(index + 1).select { |node| node.is_a?(Nodes::Step) && node.failure? }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# After the node at `index` failed: its failure steps (up to and including a fail_fast one, which
|
|
23
|
+
# cuts the track), then the exit. The failed node's own fail_fast skips the failure track.
|
|
24
|
+
# `error` / `step` are Ruby expressions (literals or locals).
|
|
25
|
+
def emit_failure(code, index, error, step, fail_fast: false)
|
|
26
|
+
return emit_exit(code, :fail_fast, error, step) if fail_fast
|
|
27
|
+
|
|
28
|
+
failures = failure_steps_after(index)
|
|
29
|
+
cut = failures.index(&:fail_fast)
|
|
30
|
+
failures.take(cut ? cut + 1 : failures.size).each do |failure|
|
|
31
|
+
@compiler.emit_failure_step(code, failure, error, step, self)
|
|
32
|
+
end
|
|
33
|
+
emit_exit(code, cut ? :fail_fast : :failure, error, step)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether an exit leaves the list (return / next). False under recovery, where an exit only
|
|
37
|
+
# records the outcome, so the nodes after it are guarded by the flag.
|
|
38
|
+
def jumps? = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The operation's own nodes: exits return the Result.
|
|
42
|
+
class TopScope < Scope
|
|
43
|
+
def emit_exit(code, terminus, error, step)
|
|
44
|
+
code.line("return #{@compiler.result}.new(#{terminus.inspect}, ctx, #{error}, #{step})")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Inside a wrap block `return` would skip the wrapper (and commit an ActiveRecord transaction),
|
|
49
|
+
# so exits record the outcome in locals and leave the block with `next`.
|
|
50
|
+
class WrapScope < Scope
|
|
51
|
+
def initialize(nodes, compiler, id, trace_var: nil)
|
|
52
|
+
super(nodes, compiler, trace_var:)
|
|
53
|
+
@id = id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def var(name) = "__w#{@id}_#{name}"
|
|
57
|
+
|
|
58
|
+
def emit_exit(code, terminus, error, step)
|
|
59
|
+
code.line("#{var(:t)} = #{terminus.inspect}")
|
|
60
|
+
code.line("#{var(:e)} = #{error}")
|
|
61
|
+
code.line("#{var(:s)} = #{step}")
|
|
62
|
+
code.line("next false")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A switch branch: no block around it, so a failure inside continues straight into the failure
|
|
67
|
+
# track after the switch in the enclosing list (unless fail_fast cut it).
|
|
68
|
+
class BranchScope < Scope
|
|
69
|
+
def initialize(nodes, compiler, parent, index, trace_var: nil)
|
|
70
|
+
super(nodes, compiler, trace_var:)
|
|
71
|
+
@parent = parent
|
|
72
|
+
@index = index
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def emit_exit(code, terminus, error, step)
|
|
76
|
+
return @parent.emit_exit(code, :fail_fast, error, step) if terminus == :fail_fast
|
|
77
|
+
|
|
78
|
+
@parent.emit_failure(code, @index, error, step)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def jumps? = @parent.jumps?
|
|
82
|
+
|
|
83
|
+
def var(name) = @parent.var(name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A list with a `failure ..., recover: true` step. The railway runs as a flat sequence over a
|
|
87
|
+
# failure flag: a failure records terminus / error / step in locals; success nodes run only while
|
|
88
|
+
# the flag is unset, failure steps only while it is :failure; a recovering step that returns truthy
|
|
89
|
+
# clears it. At the end, a set flag leaves through the scope the list belongs to (`base`).
|
|
90
|
+
class RecoveryScope < Scope
|
|
91
|
+
attr_reader :base
|
|
92
|
+
|
|
93
|
+
def initialize(base, compiler, id)
|
|
94
|
+
super(base.nodes, compiler, trace_var: base.trace_var)
|
|
95
|
+
@base = base
|
|
96
|
+
@id = id
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def var(name) = "__r#{@id}_#{name}"
|
|
100
|
+
|
|
101
|
+
# Failure steps are separate statements here, run in order under the flag.
|
|
102
|
+
def failure_steps_after(_index) = []
|
|
103
|
+
|
|
104
|
+
def emit_exit(code, terminus, error, step)
|
|
105
|
+
code.line("#{var(:t)} = #{terminus.inspect}")
|
|
106
|
+
code.line("#{var(:e)} = #{error}")
|
|
107
|
+
code.line("#{var(:s)} = #{step}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def jumps? = false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Reads a step method's parameters and emits the Ruby code that calls it.
|
|
6
|
+
class Signature
|
|
7
|
+
# Up to this many optional kwargs compile to `ctx.key?` branches; more use `ctx.slice`.
|
|
8
|
+
MAX_OPTIONAL_BRANCHES = 2
|
|
9
|
+
NON_POSITIONAL = %i[key keyreq keyrest nokey block].freeze
|
|
10
|
+
# Keywords a failure step may take that come from the failure itself, not from ctx.
|
|
11
|
+
BOUND = %i[error failed_step].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(operation, step, index, branches: true)
|
|
14
|
+
@operation = operation
|
|
15
|
+
@step = step
|
|
16
|
+
@index = index
|
|
17
|
+
@branches = branches
|
|
18
|
+
@required = []
|
|
19
|
+
@optional = []
|
|
20
|
+
@bound = []
|
|
21
|
+
parse(operation.instance_method(step.name).parameters)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# False when optional kwargs need an if/else tree of `ctx.key?` checks.
|
|
25
|
+
def single_line?
|
|
26
|
+
@optional.empty? || !@branches || @optional.size > MAX_OPTIONAL_BRANCHES
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `bindings`: Ruby expressions for the bound keywords (error:, failed_step:) of a failure step.
|
|
30
|
+
def expression(**bindings)
|
|
31
|
+
@optional.empty? ? call_with([], bindings) : sliced_call(bindings)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def emit(code, **bindings)
|
|
35
|
+
single_line? ? code.line(expression(**bindings)) : emit_branches(code, [], @optional, bindings)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse(parameters)
|
|
41
|
+
check_positional!(parameters)
|
|
42
|
+
parameters.each do |type, name|
|
|
43
|
+
next @bound << name if bound?(type, name)
|
|
44
|
+
|
|
45
|
+
check_not_bound!(type, name)
|
|
46
|
+
|
|
47
|
+
case type
|
|
48
|
+
when :keyreq then @required << name
|
|
49
|
+
when :key then @optional << name
|
|
50
|
+
when :keyrest then error!("uses ** in its signature; declare the keys it needs as keyword arguments")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def bound?(type, name)
|
|
56
|
+
%i[keyreq key].include?(type) && BOUND.include?(name) && @step.is_a?(Nodes::Step) && @step.failure?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Read from ctx these would be reserved keys (Result::RESERVED_CTX_KEYS): the step could never run.
|
|
60
|
+
def check_not_bound!(type, name)
|
|
61
|
+
return unless %i[keyreq key].include?(type) && BOUND.include?(name)
|
|
62
|
+
|
|
63
|
+
error!("takes #{name}:, which only failure steps get (it's a reserved key, never in ctx)")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check_positional!(parameters)
|
|
67
|
+
positional = parameters.map(&:first) - NON_POSITIONAL
|
|
68
|
+
return if positional == [:req]
|
|
69
|
+
|
|
70
|
+
error!("must take ctx as its only positional argument: def #{@step.name}(ctx, ...)")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pass each optional key only when ctx has it, so the method default applies otherwise.
|
|
74
|
+
def emit_branches(code, present, rest, bindings)
|
|
75
|
+
return code.line(call_with(present, bindings)) if rest.empty?
|
|
76
|
+
|
|
77
|
+
key, *tail = rest
|
|
78
|
+
code.if_else(
|
|
79
|
+
"ctx.key?(#{key.inspect})",
|
|
80
|
+
-> { emit_branches(code, present + [key], tail, bindings) },
|
|
81
|
+
-> { emit_branches(code, present, tail, bindings) }
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def call_with(optional, bindings)
|
|
86
|
+
render(base_args(bindings) + optional.map { |key| "#{key}: ctx[#{key.inspect}]" })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ctx.slice keeps only present keys, so absent ones fall back to the method defaults.
|
|
90
|
+
def sliced_call(bindings)
|
|
91
|
+
render(base_args(bindings) + ["**ctx.slice(#{@optional.map(&:inspect).join(", ")})"])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def base_args(bindings)
|
|
95
|
+
required = @required.map do |key|
|
|
96
|
+
missing = "__octorail_missing_key(#{@index}, #{key.inspect}, ctx, #{@step.name.inspect})"
|
|
97
|
+
"#{key}: ctx.fetch(#{key.inspect}) { #{missing} }"
|
|
98
|
+
end
|
|
99
|
+
["ctx", *required, *@bound.map { |key| "#{key}: #{bindings.fetch(key)}" }]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def render(args)
|
|
103
|
+
"#{@step.name}(#{args.join(", ")})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def error!(message)
|
|
107
|
+
raise CompileError, "#{@operation}: #{@step.type} #{@step.name.inspect} #{message}" \
|
|
108
|
+
"#{Octorail.declared_at(@step)}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Emits step / pass nodes. :failure steps are emitted inside the failure branches of the steps
|
|
6
|
+
# before them (see Scope#emit_failure), or as guarded statements under recovery.
|
|
7
|
+
class StepEmitter
|
|
8
|
+
def initialize(compiler)
|
|
9
|
+
@compiler = compiler
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def emit(code, step, index, scope)
|
|
13
|
+
case step.type
|
|
14
|
+
when :step then emit_railway(code, step, index, scope)
|
|
15
|
+
when :pass then emit_pass(code, step, scope)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def emit_pass(code, step, scope)
|
|
22
|
+
return @compiler.signature(step).emit(code) unless @compiler.traced?
|
|
23
|
+
|
|
24
|
+
@compiler.emit_call(code, step, scope)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def emit_railway(code, step, index, scope)
|
|
28
|
+
error = (step.error || :failure).inspect
|
|
29
|
+
code.block("unless #{@compiler.condition(code, step, scope)}") do
|
|
30
|
+
scope.emit_failure(code, index, error, step.name.inspect, fail_fast: step.fail_fast)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|