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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Emits a switch as a plain `case` over the decider's result.
|
|
6
|
+
#
|
|
7
|
+
# case __s1 = flow_for(ctx, current_user: ctx.fetch(:current_user) { ... })
|
|
8
|
+
# when :admin
|
|
9
|
+
# unless grant_all(ctx)
|
|
10
|
+
# log_failure(ctx)
|
|
11
|
+
# return Result.new(:failure, ctx, :failure, :grant_all)
|
|
12
|
+
# end
|
|
13
|
+
# when :guest
|
|
14
|
+
# else
|
|
15
|
+
# __octorail_unknown_branch(0, __s1)
|
|
16
|
+
# end
|
|
17
|
+
class SwitchEmitter
|
|
18
|
+
def initialize(compiler)
|
|
19
|
+
@compiler = compiler
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def emit(code, switch, index, scope)
|
|
23
|
+
id = @compiler.next_id
|
|
24
|
+
value = "__s#{id}"
|
|
25
|
+
trace = @compiler.start_trace(code, scope, :switch, switch.decider, id)
|
|
26
|
+
decide = @compiler.signature(switch, branches: false).expression
|
|
27
|
+
code.line("case #{value} = #{trace ? "#{trace}.taken(#{decide})" : decide}")
|
|
28
|
+
emit_branches(code, switch, index, scope, trace)
|
|
29
|
+
emit_unknown(code, switch, value)
|
|
30
|
+
@compiler.done_trace(code, scope, trace, "true")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def emit_branches(code, switch, index, scope, trace)
|
|
36
|
+
switch.branches.each do |branch|
|
|
37
|
+
code.line("when #{branch.keys.map(&:inspect).join(", ")}")
|
|
38
|
+
branch_scope = BranchScope.new(branch.nodes, @compiler, scope, index, trace_var: trace)
|
|
39
|
+
code.indented { @compiler.emit_nodes(code, branch_scope) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def emit_unknown(code, switch, value)
|
|
44
|
+
code.line("else")
|
|
45
|
+
code.indented do
|
|
46
|
+
code.line("__octorail_unknown_branch(#{@compiler.method_index(switch)}, #{value})")
|
|
47
|
+
end
|
|
48
|
+
code.line("end")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Calls of step methods, plain or traced. In the traced variant every call is wrapped into
|
|
6
|
+
# `trace.run(type, name) { call }`, which records the node (and a raise) and passes the result
|
|
7
|
+
# through; `trace` is the Ruby expression for the current container node (Scope#trace_var).
|
|
8
|
+
module Tracing
|
|
9
|
+
def traced? = @traced
|
|
10
|
+
|
|
11
|
+
# A one-line call goes straight into the condition; an if/else tree is assigned to `ok` first.
|
|
12
|
+
def condition(code, node, scope, type: node.type, **bindings)
|
|
13
|
+
sig = signature(node)
|
|
14
|
+
trace = scope.trace_var
|
|
15
|
+
run = "#{trace}.run(#{type.inspect}, #{node.name.inspect})" if trace
|
|
16
|
+
return multiline_condition(code, sig, run, bindings) unless sig.single_line?
|
|
17
|
+
|
|
18
|
+
call = sig.expression(**bindings)
|
|
19
|
+
run ? "#{run} { #{call} }" : call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def multiline_condition(code, sig, run, bindings)
|
|
23
|
+
code.line(run ? "ok = #{run} do" : "ok =")
|
|
24
|
+
code.indented { sig.emit(code, **bindings) }
|
|
25
|
+
code.line("end") if run
|
|
26
|
+
"ok"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A call whose result is not used (pass, failure step).
|
|
30
|
+
def emit_call(code, node, scope, type: node.type, **bindings)
|
|
31
|
+
call = condition(code, node, scope, type:, **bindings)
|
|
32
|
+
code.line(call) unless call == "ok"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `__tw1 = __t.start(:wrap, "Transaction")` before a container node; returns the new local.
|
|
36
|
+
def start_trace(code, scope, type, name, id)
|
|
37
|
+
return unless traced?
|
|
38
|
+
|
|
39
|
+
var = "__t#{type.to_s[0]}#{id}"
|
|
40
|
+
code.line("#{var} = #{scope.trace_var}.start(#{type.inspect}, #{name.inspect})")
|
|
41
|
+
var
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def done_trace(code, scope, var, value)
|
|
45
|
+
code.line("#{scope.trace_var}.done(#{var}, #{value})") if var
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What a trace shows for a wrapper or a nested operation.
|
|
49
|
+
def trace_label(object)
|
|
50
|
+
return object.to_s if object.is_a?(Symbol)
|
|
51
|
+
|
|
52
|
+
(object.is_a?(Module) && object.name) || object.inspect
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Compiler
|
|
5
|
+
# Emits a wrap: the wrapper call with the inner nodes compiled into its block.
|
|
6
|
+
#
|
|
7
|
+
# __w1_t = __w1_e = __w1_s = nil
|
|
8
|
+
# __w1_ok = ::Transaction.call(ctx) do
|
|
9
|
+
# __w1_t = __w1_e = __w1_s = nil
|
|
10
|
+
# unless charge(ctx)
|
|
11
|
+
# __w1_t = :failure; __w1_e = :failure; __w1_s = :charge
|
|
12
|
+
# next false
|
|
13
|
+
# end
|
|
14
|
+
# true
|
|
15
|
+
# end
|
|
16
|
+
# unless __w1_ok
|
|
17
|
+
# ...outer failure steps...
|
|
18
|
+
# return Result.new(:failure, ctx, __w1_t ? __w1_e : :failure, __w1_t ? __w1_s : nil)
|
|
19
|
+
# end
|
|
20
|
+
class WrapEmitter
|
|
21
|
+
def initialize(compiler)
|
|
22
|
+
@compiler = compiler
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def emit(code, wrap, index, scope)
|
|
26
|
+
id = @compiler.next_id
|
|
27
|
+
trace = @compiler.start_trace(code, scope, :wrap, @compiler.trace_label(wrap.wrapper), id)
|
|
28
|
+
inner = WrapScope.new(wrap.nodes, @compiler, id, trace_var: trace)
|
|
29
|
+
emit_block(code, wrap, inner)
|
|
30
|
+
@compiler.done_trace(code, scope, trace, inner.var(:ok))
|
|
31
|
+
emit_failure(code, wrap, index, inner, scope)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def emit_block(code, wrap, inner)
|
|
37
|
+
reset = "#{inner.var(:t)} = #{inner.var(:e)} = #{inner.var(:s)} = nil"
|
|
38
|
+
code.line(reset)
|
|
39
|
+
code.block("#{inner.var(:ok)} = #{wrapper_call(wrap)} do") do
|
|
40
|
+
code.line(reset) # again inside: a wrapper may yield more than once (retries)
|
|
41
|
+
@compiler.emit_nodes(code, inner)
|
|
42
|
+
code.line("true")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The wrap failed: a step inside failed (its outcome is in the locals) or the wrapper itself
|
|
47
|
+
# returned falsy (no locals set, so the wrap's own error / name apply).
|
|
48
|
+
# A fail_fast inside keeps its meaning outside: the outer failure steps are skipped too.
|
|
49
|
+
def emit_failure(code, wrap, index, inner, scope)
|
|
50
|
+
error, step = outcome(wrap, inner)
|
|
51
|
+
code.block("unless #{inner.var(:ok)}") do
|
|
52
|
+
next scope.emit_failure(code, index, error, step) unless wrap.steps.any?(&:fail_fast)
|
|
53
|
+
|
|
54
|
+
code.if_else("#{inner.var(:t)} == :fail_fast", -> { scope.emit_exit(code, :fail_fast, error, step) },
|
|
55
|
+
-> { scope.emit_failure(code, index, error, step) })
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# error / failed_step expressions: what the exit inside recorded (even nil), else the wrap's own.
|
|
60
|
+
def outcome(wrap, inner)
|
|
61
|
+
exited = inner.var(:t)
|
|
62
|
+
["#{exited} ? #{inner.var(:e)} : #{(wrap.error || :failure).inspect}",
|
|
63
|
+
"#{exited} ? #{inner.var(:s)} : #{wrap.name.inspect}"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def wrapper_call(wrap)
|
|
67
|
+
wrapper = wrap.wrapper
|
|
68
|
+
return @compiler.signature(wrap, branches: false).expression if wrapper.is_a?(Symbol)
|
|
69
|
+
|
|
70
|
+
"#{@compiler.constant_for(wrapper)}.call(ctx)"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "compiler/code"
|
|
4
|
+
require_relative "compiler/signature"
|
|
5
|
+
require_relative "compiler/scopes"
|
|
6
|
+
require_relative "compiler/step_emitter"
|
|
7
|
+
require_relative "compiler/wrap_emitter"
|
|
8
|
+
require_relative "compiler/nested_emitter"
|
|
9
|
+
require_relative "compiler/switch_emitter"
|
|
10
|
+
require_relative "compiler/recovery_emitter"
|
|
11
|
+
require_relative "compiler/nested_cycles"
|
|
12
|
+
require_relative "compiler/tracing"
|
|
13
|
+
|
|
14
|
+
module Octorail
|
|
15
|
+
# Turns an operation's nodes into Ruby source for one instance method.
|
|
16
|
+
# Walks the nodes and hands each to its emitter (compiler/*_emitter.rb); the public
|
|
17
|
+
# methods below are the toolkit emitters share.
|
|
18
|
+
class Compiler
|
|
19
|
+
METHOD_NAME = "__octorail_call"
|
|
20
|
+
# What `call` runs: METHOD_NAME itself when tracing is off, else a delegate to the traced call.
|
|
21
|
+
ENTRY_METHOD_NAME = "__octorail_entry"
|
|
22
|
+
# The traced variant (docs/tracing.md): same flow, records a Trace into `__t` (a Trace::Node).
|
|
23
|
+
TRACED_METHOD_NAME = "__octorail_traced"
|
|
24
|
+
# Per-operation Result subclass constant, defined only when the operation declares outputs.
|
|
25
|
+
RESULT_CONST = "OctorailResult"
|
|
26
|
+
# Per-operation array of objects (wrappers, nested operations) that have no constant name.
|
|
27
|
+
REFS_CONST = "OctorailRefs"
|
|
28
|
+
# Each node type is compiled by its own emitter (compiler/*_emitter.rb).
|
|
29
|
+
EMITTERS = {
|
|
30
|
+
Nodes::Step => StepEmitter,
|
|
31
|
+
Nodes::Wrap => WrapEmitter,
|
|
32
|
+
Nodes::Nested => NestedEmitter,
|
|
33
|
+
Nodes::Switch => SwitchEmitter
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Ruby expression for the operation's Result class.
|
|
37
|
+
attr_reader :result
|
|
38
|
+
# Objects to store in REFS_CONST; filled while generating the source.
|
|
39
|
+
attr_reader :anonymous_refs
|
|
40
|
+
|
|
41
|
+
include Tracing
|
|
42
|
+
|
|
43
|
+
def initialize(operation, traced: false)
|
|
44
|
+
@operation = operation
|
|
45
|
+
@traced = traced
|
|
46
|
+
@steps = operation.steps
|
|
47
|
+
@method_nodes = operation.method_nodes
|
|
48
|
+
@outputs = operation.outputs
|
|
49
|
+
@result = @outputs.empty? ? "::Octorail::Result" : RESULT_CONST
|
|
50
|
+
@signatures = {}
|
|
51
|
+
@ids = 0
|
|
52
|
+
@anonymous_refs = []
|
|
53
|
+
setup_emitters
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def source
|
|
57
|
+
@method_nodes.each { |node| check_method!(node) }
|
|
58
|
+
NestedCycles.new(@operation).check!
|
|
59
|
+
code = Code.new
|
|
60
|
+
header = traced? ? "def #{TRACED_METHOD_NAME}(ctx, __t)" : "def #{METHOD_NAME}(ctx)"
|
|
61
|
+
code.block(header) do
|
|
62
|
+
emit_nodes(code, TopScope.new(@steps, self, trace_var: ("__t" if traced?)))
|
|
63
|
+
emit_success(code)
|
|
64
|
+
emit_frozen_rescue(code)
|
|
65
|
+
end
|
|
66
|
+
code.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Under a non-jumping scope (a branch inside a recovery list) an exit only sets the flag,
|
|
70
|
+
# so every node after the first runs only while the flag is unset.
|
|
71
|
+
def emit_nodes(code, scope)
|
|
72
|
+
return @recovery.emit(code, scope) if RecoveryEmitter.needed?(scope.nodes)
|
|
73
|
+
|
|
74
|
+
flag = scope.var(:t) unless scope.jumps?
|
|
75
|
+
scope.nodes.each_with_index { |node, index| emit_node(code, node, index, scope, unless_flag: flag) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Emits one node with its emitter; `unless_flag` guards it (except the first and failure steps,
|
|
79
|
+
# which emit nothing by themselves).
|
|
80
|
+
def emit_node(code, node, index, scope, unless_flag: nil)
|
|
81
|
+
emit = -> { @emitters.fetch(node.class).emit(code, node, index, scope) }
|
|
82
|
+
return emit.call if unless_flag.nil? || index.zero? || failure_step?(node)
|
|
83
|
+
|
|
84
|
+
code.block("unless #{unless_flag}", &emit)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def failure_step?(node) = node.is_a?(Nodes::Step) && node.failure?
|
|
88
|
+
|
|
89
|
+
# A failure step's call; it may take `error:` / `failed_step:`, filled with these expressions.
|
|
90
|
+
def emit_failure_step(code, failure, error, step, scope)
|
|
91
|
+
return signature(failure).emit(code, error:, failed_step: step) unless traced?
|
|
92
|
+
|
|
93
|
+
emit_call(code, failure, scope, type: :failure, error:, failed_step: step)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Every declared output must be in ctx before the successful result is built.
|
|
97
|
+
def emit_success(code)
|
|
98
|
+
@outputs.each do |name|
|
|
99
|
+
code.line("__octorail_missing_output(#{name.inspect}, ctx) unless ctx.key?(#{name.inspect})")
|
|
100
|
+
end
|
|
101
|
+
code.line("#{@result}.new(:success, ctx)")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `branches: false` keeps the call on one line (optional kwargs via ctx.slice), e.g. for a
|
|
105
|
+
# wrapper method, where an if/else tree would duplicate the whole block.
|
|
106
|
+
# Per node, not per name: the same method may be a step in two branches of a switch.
|
|
107
|
+
def signature(node, branches: true)
|
|
108
|
+
cache = @signatures[branches] ||= {}.compare_by_identity
|
|
109
|
+
cache[node] ||= Signature.new(@operation, node, method_index(node), branches:)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The node's position in `method_nodes`, baked into the generated error calls (missing key,
|
|
113
|
+
# unknown branch), so the error names this very declaration, not another one with its name.
|
|
114
|
+
def method_index(node) = @method_nodes.index { |candidate| candidate.equal?(node) }
|
|
115
|
+
|
|
116
|
+
# Unique suffix for the locals of one wrap / nested node.
|
|
117
|
+
def next_id = @ids += 1
|
|
118
|
+
|
|
119
|
+
# A named class/module is referenced by its constant; anything else goes to REFS_CONST.
|
|
120
|
+
def constant_for(object)
|
|
121
|
+
name = constant_name(object)
|
|
122
|
+
return "::#{name}" if name
|
|
123
|
+
|
|
124
|
+
@anonymous_refs << object
|
|
125
|
+
"#{REFS_CONST}[#{@anonymous_refs.size - 1}]"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def setup_emitters
|
|
131
|
+
@emitters = EMITTERS.transform_values { |emitter| emitter.new(self) }
|
|
132
|
+
@recovery = RecoveryEmitter.new(self)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def constant_name(object)
|
|
136
|
+
name = object.is_a?(Module) && object.name
|
|
137
|
+
name if name && Object.const_defined?(name) && Object.const_get(name).equal?(object)
|
|
138
|
+
rescue NameError # a temporary name (Module#set_temporary_name) is not a constant path
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Free unless raised: turns "can't modify frozen Users::Create" from an ivar write into a clear error.
|
|
143
|
+
def emit_frozen_rescue(code)
|
|
144
|
+
code.dedented { code.line("rescue FrozenError => e") }
|
|
145
|
+
code.line("__octorail_frozen!(e)")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Methods every object has (Kernel#test, Object#display, ...) don't count as a step's method.
|
|
149
|
+
def check_method!(node)
|
|
150
|
+
name = node.name
|
|
151
|
+
defined = @operation.method_defined?(name) || @operation.private_method_defined?(name)
|
|
152
|
+
owner = @operation.instance_method(name).owner if defined
|
|
153
|
+
return if defined && !(owner >= Operation)
|
|
154
|
+
|
|
155
|
+
builtin = " (only #{owner}##{name}, which is not a step)" if defined
|
|
156
|
+
raise CompileError, "#{@operation}: #{node.type} #{name.inspect} has no instance method ##{name}#{builtin}" \
|
|
157
|
+
"#{Octorail.declared_at(node)}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
# Global settings (docs/configuration.md). An operation overrides them with `configure` (dsl/settings.rb).
|
|
5
|
+
class Config
|
|
6
|
+
# Settings an operation may override, with their allowed values.
|
|
7
|
+
OPERATION_SETTINGS = { trace: Trace::LEVELS }.freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :trace
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@trace = :off
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Changes compiled code, so every compiled operation recompiles on its next call.
|
|
16
|
+
def trace=(level)
|
|
17
|
+
@trace = Config.check!(:trace, level, "Octorail.config")
|
|
18
|
+
Operation.invalidate!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.check!(key, value, owner)
|
|
22
|
+
allowed = OPERATION_SETTINGS.fetch(key)
|
|
23
|
+
return value if allowed.include?(value)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "#{owner}: #{key} must be one of #{Octorail.key_list(allowed)}, got #{value.inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.config = @config ||= Config.new
|
|
30
|
+
|
|
31
|
+
def self.configure
|
|
32
|
+
yield config
|
|
33
|
+
config
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module DSL
|
|
5
|
+
# nested: run another operation as a step, on the same ctx.
|
|
6
|
+
module Nested
|
|
7
|
+
def nested(operation, error: nil, fail_fast: false)
|
|
8
|
+
check_not_abstract!("nested #{operation}")
|
|
9
|
+
check_nested!(operation, error, fail_fast)
|
|
10
|
+
current_nodes << Nodes::Nested.new(operation:, error:, fail_fast:, location: declaration_location)
|
|
11
|
+
invalidate!
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def check_nested!(operation, error, fail_fast)
|
|
18
|
+
unless operation.is_a?(Class) && operation < Operation
|
|
19
|
+
raise ArgumentError, "#{self}: nested: expected an Octorail::Operation subclass, got #{operation.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
check_not_recursive!(operation)
|
|
23
|
+
unless error.nil? || error.is_a?(Symbol)
|
|
24
|
+
raise ArgumentError, "#{self}: nested #{operation}: error: must be a Symbol, got #{error.inspect}"
|
|
25
|
+
end
|
|
26
|
+
return if [true, false].include?(fail_fast)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "#{self}: nested #{operation}: fail_fast: must be true or false, got #{fail_fast.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A subclass inherits this very nested step, so nesting it would recurse forever too.
|
|
32
|
+
def check_not_recursive!(operation)
|
|
33
|
+
return unless operation <= self
|
|
34
|
+
|
|
35
|
+
what = operation.equal?(self) ? "itself" : "its own subclass"
|
|
36
|
+
raise ArgumentError, "#{self}: nested #{operation}: an operation can't nest #{what}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module DSL
|
|
5
|
+
# output: the operation's declared public result keys.
|
|
6
|
+
module Outputs
|
|
7
|
+
def output(*names)
|
|
8
|
+
check_not_abstract!("output #{names.map(&:inspect).join(", ")}")
|
|
9
|
+
names.each do |name|
|
|
10
|
+
check_output!(name)
|
|
11
|
+
own_outputs << name unless outputs.include?(name)
|
|
12
|
+
# A subclass that declared it already now inherits it instead.
|
|
13
|
+
descendants.each { |subclass| subclass.__send__(:own_outputs).delete(name) }
|
|
14
|
+
end
|
|
15
|
+
invalidate!
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def outputs
|
|
20
|
+
parent = superclass.respond_to?(:outputs) ? superclass.outputs : []
|
|
21
|
+
parent + own_outputs
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def own_outputs = @own_outputs ||= []
|
|
27
|
+
|
|
28
|
+
def check_output!(name)
|
|
29
|
+
check_name!(name, "output")
|
|
30
|
+
return unless Result::BUILTIN_KEYS.include?(name) || Result.method_defined?(name) ||
|
|
31
|
+
Result.private_method_defined?(name)
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "#{self}: output #{name.inspect} collides with a Result field or method"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module DSL
|
|
5
|
+
# configure: per-operation settings over the global Octorail.config (docs/configuration.md).
|
|
6
|
+
# Allowed in an abstract! base too: these are settings, not flow.
|
|
7
|
+
module Settings
|
|
8
|
+
def configure(**settings)
|
|
9
|
+
settings.each do |key, value|
|
|
10
|
+
unless Config::OPERATION_SETTINGS.key?(key)
|
|
11
|
+
raise ArgumentError, "#{self}: configure: unknown setting #{key.inspect}, " \
|
|
12
|
+
"allowed: #{Octorail.key_list(Config::OPERATION_SETTINGS.keys)}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
own_settings[key] = Config.check!(key, value, "#{self}: configure")
|
|
16
|
+
end
|
|
17
|
+
invalidate!
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The operation's own value, else the nearest ancestor's, else the global one.
|
|
22
|
+
def setting(key)
|
|
23
|
+
return own_settings[key] if own_settings.key?(key)
|
|
24
|
+
return superclass.setting(key) if superclass.respond_to?(:setting)
|
|
25
|
+
|
|
26
|
+
Octorail.config.public_send(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def own_settings = @own_settings ||= {}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module DSL
|
|
5
|
+
# step / pass / failure.
|
|
6
|
+
module Steps
|
|
7
|
+
OPTIONS = {
|
|
8
|
+
step: %i[error fail_fast],
|
|
9
|
+
pass: [],
|
|
10
|
+
failure: %i[fail_fast recover]
|
|
11
|
+
}.freeze
|
|
12
|
+
FLAGS = %i[fail_fast recover].freeze
|
|
13
|
+
BOOLEANS = [true, false].freeze
|
|
14
|
+
|
|
15
|
+
def step(name, **options) = add_step(:step, name, options)
|
|
16
|
+
def pass(name, **options) = add_step(:pass, name, options)
|
|
17
|
+
def failure(name, **options) = add_step(:failure, name, options)
|
|
18
|
+
|
|
19
|
+
# Top-level nodes (steps, wraps, nested, switches) in execution order: inherited ones first, then own.
|
|
20
|
+
def steps
|
|
21
|
+
parent = superclass.respond_to?(:steps) ? superclass.steps : []
|
|
22
|
+
parent + own_steps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Every step and nested operation, including the ones inside wraps and branches, in declaration order.
|
|
26
|
+
def all_steps = Nodes.leaves(steps)
|
|
27
|
+
|
|
28
|
+
# Nodes whose instance method the compiled code calls: steps, Symbol wrappers, switch deciders.
|
|
29
|
+
def method_nodes
|
|
30
|
+
Nodes.walk(steps).select do |node|
|
|
31
|
+
node.is_a?(Nodes::Step) || node.is_a?(Nodes::Switch) || (node.is_a?(Nodes::Wrap) && node.name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def step_method_names = method_nodes.map(&:name)
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def own_steps = @own_steps ||= []
|
|
40
|
+
|
|
41
|
+
# Where a step declared right now goes: the innermost open wrap block, or the class itself.
|
|
42
|
+
def current_nodes
|
|
43
|
+
@__octorail_nesting&.last || own_steps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Steps of this class's whole line: ancestors, itself (with open wrap / branch blocks) and
|
|
47
|
+
# descendants, so that reopening a parent can't duplicate a step a subclass already has.
|
|
48
|
+
# Steps in other branches of a switch being declared are left out: branches never both run.
|
|
49
|
+
def related_steps
|
|
50
|
+
pending = (@__octorail_nesting || []).flatten
|
|
51
|
+
nodes = steps + pending + descendants.flat_map { |subclass| subclass.__send__(:own_steps) }
|
|
52
|
+
siblings = sibling_branch_steps
|
|
53
|
+
Nodes.leaves(nodes).grep(Nodes::Step).reject { |node| siblings.any? { |sibling| sibling.equal?(node) } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_step(type, name, options)
|
|
57
|
+
check_not_abstract!("#{type} #{name.inspect}")
|
|
58
|
+
check_name!(name, type)
|
|
59
|
+
check_options!(type, name, options)
|
|
60
|
+
check_duplicate_step!(type, name)
|
|
61
|
+
|
|
62
|
+
current_nodes << Nodes::Step.new(type:, name:, location: declaration_location, **options)
|
|
63
|
+
invalidate!
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_duplicate_step!(type, name)
|
|
68
|
+
original = related_steps.find { |node| node.name == name }
|
|
69
|
+
if original
|
|
70
|
+
raise ArgumentError, "#{self}: #{type} #{name.inspect} is already declared " \
|
|
71
|
+
"(#{original.type} #{name.inspect})#{Octorail.declared_at(original)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
check_sibling_type!(type, name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A method reused in another branch keeps its type: a failure step's `error:` / `failed_step:`
|
|
78
|
+
# come from the failure, a step's from ctx, and one compiled method can't be both.
|
|
79
|
+
def check_sibling_type!(type, name)
|
|
80
|
+
sibling = sibling_branch_steps.find { |node| node.is_a?(Nodes::Step) && node.name == name }
|
|
81
|
+
return if sibling.nil? || sibling.type == type
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "#{self}: #{type} #{name.inspect} is #{sibling.type} #{name.inspect} in another " \
|
|
84
|
+
"branch; a step reused across branches keeps its type#{Octorail.declared_at(sibling)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_options!(type, name, options)
|
|
88
|
+
label = "#{self}: #{type} #{name.inspect}"
|
|
89
|
+
allowed = OPTIONS.fetch(type)
|
|
90
|
+
unknown = options.keys - allowed
|
|
91
|
+
raise ArgumentError, "#{label}: unknown options #{unknown.inspect}, allowed: #{allowed.inspect}" if unknown.any?
|
|
92
|
+
|
|
93
|
+
check_option_values!(label, options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_option_values!(label, options)
|
|
97
|
+
error = options[:error]
|
|
98
|
+
unless error.nil? || error.is_a?(Symbol)
|
|
99
|
+
raise ArgumentError, "#{label}: error: must be a Symbol, got #{error.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
FLAGS.each do |flag|
|
|
103
|
+
value = options.fetch(flag, false)
|
|
104
|
+
next if BOOLEANS.include?(value)
|
|
105
|
+
|
|
106
|
+
raise ArgumentError, "#{label}: #{flag}: must be true or false, got #{value.inspect}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module DSL
|
|
5
|
+
# switch / on: run one of several branches, picked by a decider method's result.
|
|
6
|
+
module Switches
|
|
7
|
+
def switch(decider, &block)
|
|
8
|
+
check_not_abstract!("switch #{decider.inspect}")
|
|
9
|
+
check_name!(decider, "switch")
|
|
10
|
+
raise ArgumentError, "#{self}: switch #{decider.inspect} needs a block with on(...) branches" unless block
|
|
11
|
+
|
|
12
|
+
location = declaration_location
|
|
13
|
+
branches = collect_branches(decider, &block)
|
|
14
|
+
raise ArgumentError, "#{self}: switch #{decider.inspect} has no on(...) branches" if branches.empty?
|
|
15
|
+
|
|
16
|
+
current_nodes << Nodes::Switch.new(decider:, branches:, location:)
|
|
17
|
+
invalidate!
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A branch without a block is an explicit "nothing to do for these keys".
|
|
22
|
+
def on(*keys, &block)
|
|
23
|
+
switch = @__octorail_switches&.last
|
|
24
|
+
unless switch && @__octorail_nesting.size == switch[:depth]
|
|
25
|
+
raise ArgumentError, "#{self}: on(...) is only allowed directly inside a switch block"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
check_branch_keys!(switch, keys)
|
|
29
|
+
switch[:branches] << Nodes::Branch.new(keys:, nodes: block ? collect_nodes(&block) : [])
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Steps declared in the switch block but outside on(...) land in its own buffer: an error.
|
|
36
|
+
def collect_branches(decider, &)
|
|
37
|
+
switch = { decider:, branches: [], keys: [] }
|
|
38
|
+
(@__octorail_switches ||= []).push(switch)
|
|
39
|
+
stray = collect_nodes do
|
|
40
|
+
switch[:depth] = @__octorail_nesting.size
|
|
41
|
+
yield
|
|
42
|
+
end
|
|
43
|
+
return switch[:branches] if stray.empty?
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "#{self}: switch #{decider.inspect}: steps must be inside on(...) branches"
|
|
46
|
+
ensure
|
|
47
|
+
@__octorail_switches.pop
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_branch_keys!(switch, keys)
|
|
51
|
+
label = "#{self}: switch #{switch[:decider].inspect}"
|
|
52
|
+
raise ArgumentError, "#{label}: on needs at least one key" if keys.empty?
|
|
53
|
+
raise ArgumentError, "#{label}: on #{keys.inspect} repeats a key" if keys.uniq.size < keys.size
|
|
54
|
+
|
|
55
|
+
keys.each { |key| check_branch_key!(label, switch, key) }
|
|
56
|
+
switch[:keys].concat(keys)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_branch_key!(label, switch, key)
|
|
60
|
+
raise ArgumentError, "#{label}: on keys must be Symbols, got #{key.inspect}" unless key.is_a?(Symbol)
|
|
61
|
+
raise ArgumentError, "#{label}: on #{key.inspect} is already a branch" if switch[:keys].include?(key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Steps of the finished branches of every switch being declared right now.
|
|
65
|
+
def sibling_branch_steps
|
|
66
|
+
branches = (@__octorail_switches || []).flat_map { |switch| switch[:branches] }
|
|
67
|
+
Nodes.leaves(branches.flat_map(&:nodes))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|