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 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
+ [![CI](https://img.shields.io/github/actions/workflow/status/Octorail-team/octorail/ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&label=CI)](https://github.com/Octorail-team/octorail/actions/workflows/ci.yml)
8
+ [![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FOctorail-team%2Foctorail%2Fbadges%2Fcoverage.json&style=flat-square&cacheSeconds=300)](https://github.com/Octorail-team/octorail/actions/workflows/ci.yml)
9
+ [![Ruby](https://img.shields.io/badge/ruby-3.3%20%7C%203.4%20%7C%204.0-blue?style=flat-square&logo=ruby&logoColor=white)](https://www.ruby-lang.org)
10
+ [![YJIT](https://img.shields.io/badge/YJIT-tested-blue?style=flat-square&logo=ruby&logoColor=white)](https://github.com/Octorail-team/octorail/blob/main/docs/performance.md)
11
+ [![Dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square)](https://github.com/Octorail-team/octorail/blob/main/octorail.gemspec)
12
+ [![RuboCop](https://img.shields.io/github/actions/workflow/status/Octorail-team/octorail/lint.yml?branch=main&style=flat-square&logo=rubocop&logoColor=white&label=rubocop)](https://github.com/Octorail-team/octorail/actions/workflows/lint.yml)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](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