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.
@@ -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