A framework for building tree-structured executable workflows in Ruby.
Construct trees of callable nodes to handle complex execution flows. Supports strategies to seek specific handlers, broadcast to multiple listeners, or compose processing pipelines. Nodes are matched against input and executed in a chain, offering a structured, modular alternative to complex conditional logic.
Add this line to your application's Gemfile:
gem 'callable_tree'And then execute:
$ bundle install
Or install it yourself as:
$ gem install callable_tree
Builds a tree of CallableTree nodes. Invokes the call method on nodes where match? returns a truthy value, chaining execution from root to leaf.
CallableTree::Node::Internal- Defines a node that can have child nodes. Supports several strategies (
seekable,broadcastable,composable).
- Defines a node that can have child nodes. Supports several strategies (
CallableTree::Node::External- Defines a leaf node, which cannot have child nodes.
CallableTree::Node::Root- Includes
CallableTree::Node::Internal. Use this class when customization of the internal node is not required.
- Includes
There are three ways to define nodes: class style, builder style, and factory style.
This strategy stops processing subsequent sibling nodes if the current node's call method returns a non-nil value. This behavior is changeable by overriding the terminate? method.
examples/class/internal-seekable.rb:
module Node
module JSON
class Parser
include CallableTree::Node::Internal
def match?(input, **_options)
File.extname(input) == '.json'
end
# Override `call` if you need to transform input values for child nodes.
def call(input, **options)
File.open(input) do |file|
json = ::JSON.load(file)
super(json, **options)
end
end
# Override `terminate?` to return `true` to stop processing sibling nodes even if `call` returns `nil`.
def terminate?(_output, *_inputs, **_options)
true
end
end
class Scraper
include CallableTree::Node::External
def initialize(type:)
@type = type
end
def match?(input, **_options)
!!input[@type.to_s]
end
def call(input, **_options)
input[@type.to_s]
.to_h { |element| [element['name'], element['emoji']] }
end
end
end
module XML
class Parser
include CallableTree::Node::Internal
def match?(input, **_options)
File.extname(input) == '.xml'
end
# Override `call` if you need to transform input values for child nodes.
def call(input, **options)
File.open(input) do |file|
super(REXML::Document.new(file), **options)
end
end
# Override `terminate?` to return `true` to stop processing sibling nodes even if `call` returns `nil`.
def terminate?(_output, *_inputs, **_options)
true
end
end
class Scraper
include CallableTree::Node::External
def initialize(type:)
@type = type
end
def match?(input, **_options)
!input.get_elements("//#{@type}").empty?
end
def call(input, **_options)
input
.get_elements("//#{@type}")
.first
.to_h { |element| [element['name'], element['emoji']] }
end
end
end
end
# The `seekable` call can be omitted as it is the default strategy.
tree = CallableTree::Node::Root.new.seekable.append(
Node::JSON::Parser.new.seekable.append(
Node::JSON::Scraper.new(type: :animals),
Node::JSON::Scraper.new(type: :fruits)
),
Node::XML::Parser.new.seekable.append(
Node::XML::Scraper.new(type: :animals),
Node::XML::Scraper.new(type: :fruits)
)
)
Dir.glob("#{__dir__}/docs/*") do |file|
options = { foo: :bar }
pp tree.call(file, **options)
puts '---'
endRun examples/class/internal-seekable.rb:
% ruby examples/class/internal-seekable.rb
{"Dog"=>"πΆ", "Cat"=>"π±"}
---
{"Dog"=>"πΆ", "Cat"=>"π±"}
---
{"Red Apple"=>"π", "Green Apple"=>"π"}
---
{"Red Apple"=>"π", "Green Apple"=>"π"}
---examples/builder/internal-seekable.rb:
JSONParser =
CallableTree::Node::Internal::Builder
.new
.matcher do |input, **_options|
File.extname(input) == '.json'
end
.caller do |input, **options, &original|
File.open(input) do |file|
json = ::JSON.load(file)
# The following block call is equivalent to calling `super` in the class style.
original.call(json, **options)
end
end
.terminator { true }
.build
XMLParser =
CallableTree::Node::Internal::Builder
.new
.matcher do |input, **_options|
File.extname(input) == '.xml'
end
.caller do |input, **options, &original|
File.open(input) do |file|
# The following block call is equivalent to calling `super` in the class style.
original.call(REXML::Document.new(file), **options)
end
end
.terminator { true }
.build
def build_json_scraper(type)
CallableTree::Node::External::Builder
.new
.matcher do |input, **_options|
!!input[type.to_s]
end
.caller do |input, **_options|
input[type.to_s]
.to_h { |element| [element['name'], element['emoji']] }
end
.build
end
AnimalsJSONScraper = build_json_scraper(:animals)
FruitsJSONScraper = build_json_scraper(:fruits)
def build_xml_scraper(type)
CallableTree::Node::External::Builder
.new
.matcher do |input, **_options|
!input.get_elements("//#{type}").empty?
end
.caller do |input, **_options|
input
.get_elements("//#{type}")
.first
.to_h { |element| [element['name'], element['emoji']] }
end
.build
end
AnimalsXMLScraper = build_xml_scraper(:animals)
FruitsXMLScraper = build_xml_scraper(:fruits)
tree = CallableTree::Node::Root.new.seekable.append(
JSONParser.new.seekable.append(
AnimalsJSONScraper.new,
FruitsJSONScraper.new
),
XMLParser.new.seekable.append(
AnimalsXMLScraper.new,
FruitsXMLScraper.new
)
)
Dir.glob("#{__dir__}/../docs/*") do |file|
options = { foo: :bar }
pp tree.call(file, **options)
puts '---'
endRun examples/builder/internal-seekable.rb:
% ruby examples/builder/internal-seekable.rb
{"Dog"=>"πΆ", "Cat"=>"π±"}
---
{"Dog"=>"πΆ", "Cat"=>"π±"}
---
{"Red Apple"=>"π", "Green Apple"=>"π"}
---
{"Red Apple"=>"π", "Green Apple"=>"π"}
---Factory style defines behaviors as procs first, then assembles the tree structure separately. This makes the tree structure clearly visible.
examples/factory/internal-seekable.rb:
# === Behavior Definitions ===
json_matcher = ->(input, **) { File.extname(input) == '.json' }
json_caller = lambda do |input, **options, &original|
File.open(input) do |file|
json = JSON.parse(file.read)
original.call(json, **options)
end
end
xml_matcher = ->(input, **) { File.extname(input) == '.xml' }
xml_caller = lambda do |input, **options, &original|
File.open(input) do |file|
original.call(REXML::Document.new(file), **options)
end
end
animals_json_matcher = ->(input, **) { !input['animals'].nil? }
animals_json_caller = ->(input, **) { input['animals'].to_h { |e| [e['name'], e['emoji']] } }
fruits_json_matcher = ->(input, **) { !input['fruits'].nil? }
fruits_json_caller = ->(input, **) { input['fruits'].to_h { |e| [e['name'], e['emoji']] } }
animals_xml_matcher = ->(input, **) { !input.get_elements('//animals').empty? }
animals_xml_caller = ->(input, **) { input.get_elements('//animals').first.to_h { |e| [e['name'], e['emoji']] } }
fruits_xml_matcher = ->(input, **) { !input.get_elements('//fruits').empty? }
fruits_xml_caller = ->(input, **) { input.get_elements('//fruits').first.to_h { |e| [e['name'], e['emoji']] } }
terminator_true = ->(*) { true }
# === Tree Structure (clearly visible!) ===
tree = CallableTree::Node::Root.new.seekable.append(
CallableTree::Node::Internal.create(
matcher: json_matcher,
caller: json_caller,
terminator: terminator_true
).seekable.append(
CallableTree::Node::External.create(matcher: animals_json_matcher, caller: animals_json_caller),
CallableTree::Node::External.create(matcher: fruits_json_matcher, caller: fruits_json_caller)
),
CallableTree::Node::Internal.create(
matcher: xml_matcher,
caller: xml_caller,
terminator: terminator_true
).seekable.append(
CallableTree::Node::External.create(matcher: animals_xml_matcher, caller: animals_xml_caller),
CallableTree::Node::External.create(matcher: fruits_xml_matcher, caller: fruits_xml_caller)
)
)
Dir.glob("#{__dir__}/../docs/*") do |file|
options = { foo: :bar }
pp tree.call(file, **options)
puts '---'
endRun examples/factory/internal-seekable.rb:
% ruby examples/factory/internal-seekable.rb
{"Dog"=>"πΆ", "Cat"=>"π±"}
---
{"Dog"=>"πΆ", "Cat"=>"π±"}
---
{"Red Apple"=>"π", "Green Apple"=>"π"}
---
{"Red Apple"=>"π", "Green Apple"=>"π"}
---This strategy broadcasts input to all child nodes and returns their results as an array. It ignores child terminate? methods by default.
examples/class/internal-broadcastable.rb:
module Node
class LessThan
include CallableTree::Node::Internal
def initialize(num)
@num = num
end
def match?(input)
super && input < @num
end
end
end
tree = CallableTree::Node::Root.new.broadcastable.append(
Node::LessThan.new(5).broadcastable.append(
->(input) { input * 2 }, # anonymous external node
->(input) { input + 1 } # anonymous external node
),
Node::LessThan.new(10).broadcastable.append(
->(input) { input * 3 }, # anonymous external node
->(input) { input - 1 } # anonymous external node
)
)
(0..10).each do |input|
output = tree.call(input)
puts "#{input} -> #{output}"
endRun examples/class/internal-broadcastable.rb:
% ruby examples/class/internal-broadcastable.rb
0 -> [[0, 1], [0, -1]]
1 -> [[2, 2], [3, 0]]
2 -> [[4, 3], [6, 1]]
3 -> [[6, 4], [9, 2]]
4 -> [[8, 5], [12, 3]]
5 -> [nil, [15, 4]]
6 -> [nil, [18, 5]]
7 -> [nil, [21, 6]]
8 -> [nil, [24, 7]]
9 -> [nil, [27, 8]]
10 -> [nil, nil]examples/builder/internal-broadcastable.rb:
def less_than(num)
# The following block call is equivalent to calling `super` in the class style.
proc { |input, &original| original.call(input) && input < num }
end
LessThan5 =
CallableTree::Node::Internal::Builder
.new
.matcher(&method(:less_than).call(5))
.build
LessThan10 =
CallableTree::Node::Internal::Builder
.new
.matcher(&method(:less_than).call(10))
.build
def add(num)
proc { |input| input + num }
end
Add1 =
CallableTree::Node::External::Builder
.new
.caller(&method(:add).call(1))
.build
def subtract(num)
proc { |input| input - num }
end
Subtract1 =
CallableTree::Node::External::Builder
.new
.caller(&method(:subtract).call(1))
.build
def multiply(num)
proc { |input| input * num }
end
Multiply2 =
CallableTree::Node::External::Builder
.new
.caller(&method(:multiply).call(2))
.build
Multiply3 =
CallableTree::Node::External::Builder
.new
.caller(&method(:multiply).call(3))
.build
tree = CallableTree::Node::Root.new.broadcastable.append(
LessThan5.new.broadcastable.append(
Multiply2.new,
Add1.new
),
LessThan10.new.broadcastable.append(
Multiply3.new,
Subtract1.new
)
)
(0..10).each do |input|
output = tree.call(input)
puts "#{input} -> #{output}"
endRun examples/builder/internal-broadcastable.rb:
% ruby examples/builder/internal-broadcastable.rb
0 -> [[0, 1], [0, -1]]
1 -> [[2, 2], [3, 0]]
2 -> [[4, 3], [6, 1]]
3 -> [[6, 4], [9, 2]]
4 -> [[8, 5], [12, 3]]
5 -> [nil, [15, 4]]
6 -> [nil, [18, 5]]
7 -> [nil, [21, 6]]
8 -> [nil, [24, 7]]
9 -> [nil, [27, 8]]
10 -> [nil, nil]examples/factory/internal-broadcastable.rb:
# === Behavior Definitions ===
less_than_5_matcher = ->(input, **, &original) { original.call(input) && input < 5 }
less_than_10_matcher = ->(input, **, &original) { original.call(input) && input < 10 }
multiply_2_caller = ->(input, **) { input * 2 }
add_1_caller = ->(input, **) { input + 1 }
multiply_3_caller = ->(input, **) { input * 3 }
subtract_1_caller = ->(input, **) { input - 1 }
# === Tree Structure ===
tree = CallableTree::Node::Root.new.broadcastable.append(
CallableTree::Node::Internal.create(matcher: less_than_5_matcher).broadcastable.append(
CallableTree::Node::External.create(caller: multiply_2_caller),
CallableTree::Node::External.create(caller: add_1_caller)
),
CallableTree::Node::Internal.create(matcher: less_than_10_matcher).broadcastable.append(
CallableTree::Node::External.create(caller: multiply_3_caller),
CallableTree::Node::External.create(caller: subtract_1_caller)
)
)
(0..10).each do |input|
output = tree.call(input)
puts "#{input} -> #{output}"
endRun examples/factory/internal-broadcastable.rb:
% ruby examples/factory/internal-broadcastable.rb
0 -> [[0, 1], [0, -1]]
1 -> [[2, 2], [3, 0]]
2 -> [[4, 3], [6, 1]]
3 -> [[6, 4], [9, 2]]
4 -> [[8, 5], [12, 3]]
5 -> [nil, [15, 4]]
6 -> [nil, [18, 5]]
7 -> [nil, [21, 6]]
8 -> [nil, [24, 7]]
9 -> [nil, [27, 8]]
10 -> [nil, nil]This strategy chains child nodes, passing the output of the previous node as input to the next.
It also ignores their terminate? methods by default.
examples/class/internal-composable.rb:
module Node
class LessThan
include CallableTree::Node::Internal
def initialize(num)
@num = num
end
def match?(input)
super && input < @num
end
end
end
tree = CallableTree::Node::Root.new.composable.append(
Node::LessThan.new(5).composable.append(
proc { |input| input * 2 }, # anonymous external node
proc { |input| input + 1 } # anonymous external node
),
Node::LessThan.new(10).composable.append(
proc { |input| input * 3 }, # anonymous external node
proc { |input| input - 1 } # anonymous external node
)
)
(0..10).each do |input|
output = tree.call(input)
puts "#{input} -> #{output}"
endRun examples/class/internal-composable.rb:
% ruby examples/class/internal-composable.rb
0 -> 2
1 -> 8
2 -> 14
3 -> 20
4 -> 26
5 -> 14
6 -> 17
7 -> 20
8 -> 23
9 -> 26
10 -> 10examples/builder/internal-composable.rb:
def less_than(num)
# The following block call is equivalent to calling `super` in the class style.
proc { |input, &original| original.call(input) && input < num }
end
LessThan5 =
CallableTree::Node::Internal::Builder
.new
.matcher(&method(:less_than).call(5))
.build
LessThan10 =
CallableTree::Node::Internal::Builder
.new
.matcher(&method(:less_than).call(10))
.build
def add(num)
proc { |input| input + num }
end
Add1 =
CallableTree::Node::External::Builder
.new
.caller(&method(:add).call(1))
.build
def subtract(num)
proc { |input| input - num }
end
Subtract1 =
CallableTree::Node::External::Builder
.new
.caller(&method(:subtract).call(1))
.build
def multiply(num)
proc { |input| input * num }
end
Multiply2 =
CallableTree::Node::External::Builder
.new
.caller(&method(:multiply).call(2))
.build
Multiply3 =
CallableTree::Node::External::Builder
.new
.caller(&method(:multiply).call(3))
.build
tree = CallableTree::Node::Root.new.composable.append(
LessThan5.new.composable.append(
Multiply2.new,
Add1.new
),
LessThan10.new.composable.append(
Multiply3.new,
Subtract1.new
)
)
(0..10).each do |input|
output = tree.call(input)
puts "#{input} -> #{output}"
endRun examples/builder/internal-composable.rb:
% ruby examples/builder/internal-composable.rb
0 -> 2
1 -> 8
2 -> 14
3 -> 20
4 -> 26
5 -> 14
6 -> 17
7 -> 20
8 -> 23
9 -> 26
10 -> 10examples/factory/internal-composable.rb:
# === Behavior Definitions ===
less_than_5_matcher = ->(input, **, &original) { original.call(input) && input < 5 }
less_than_10_matcher = ->(input, **, &original) { original.call(input) && input < 10 }
multiply_2_caller = ->(input, **) { input * 2 }
add_1_caller = ->(input, **) { input + 1 }
multiply_3_caller = ->(input, **) { input * 3 }
subtract_1_caller = ->(input, **) { input - 1 }
# === Tree Structure ===
tree = CallableTree::Node::Root.new.composable.append(
CallableTree::Node::Internal.create(matcher: less_than_5_matcher).composable.append(
CallableTree::Node::External.create(caller: multiply_2_caller),
CallableTree::Node::External.create(caller: add_1_caller)
),
CallableTree::Node::Internal.create(matcher: less_than_10_matcher).composable.append(
CallableTree::Node::External.create(caller: multiply_3_caller),
CallableTree::Node::External.create(caller: subtract_1_caller)
)
)
(0..10).each do |input|
output = tree.call(input)
puts "#{input} -> #{output}"
endRun examples/factory/internal-composable.rb:
% ruby examples/factory/internal-composable.rb
0 -> 2
1 -> 8
2 -> 14
3 -> 20
4 -> 26
5 -> 14
6 -> 17
7 -> 20
8 -> 23
9 -> 26
10 -> 10Use this method to enable verbose output.
examples/builder/external-verbosify.rb:
...
tree = CallableTree::Node::Root.new.seekable.append(
JSONParser.new.seekable.append(
AnimalsJSONScraper.new.verbosify,
FruitsJSONScraper.new.verbosify
),
XMLParser.new.seekable.append(
AnimalsXMLScraper.new.verbosify,
FruitsXMLScraper.new.verbosify
)
)
...Run examples/builder/external-verbosify.rb:
% ruby examples/class/external-verbosify.rb
#<struct CallableTree::Node::External::Output
value={"Dog"=>"πΆ", "Cat"=>"π±"},
options={:foo=>:bar},
routes=[AnimalsJSONScraper, JSONParser, CallableTree::Node::Root]>
---
#<struct CallableTree::Node::External::Output
value={"Dog"=>"πΆ", "Cat"=>"π±"},
options={:foo=>:bar},
routes=[AnimalsXMLScraper, XMLParser, CallableTree::Node::Root]>
---
#<struct CallableTree::Node::External::Output
value={"Red Apple"=>"π", "Green Apple"=>"π"},
options={:foo=>:bar},
routes=[FruitsJSONScraper, JSONParser, CallableTree::Node::Root]>
---
#<struct CallableTree::Node::External::Output
value={"Red Apple"=>"π", "Green Apple"=>"π"},
options={:foo=>:bar},
routes=[FruitsXMLScraper, XMLParser, CallableTree::Node::Root]>
---This is an example of logging.
examples/builder/logging.rb:
JSONParser =
CallableTree::Node::Internal::Builder
.new
...
.hookable
.build
XMLParser =
CallableTree::Node::Internal::Builder
.new
...
.hookable
.build
def build_json_scraper(type)
CallableTree::Node::External::Builder
.new
...
.hookable
.build
end
...
def build_xml_scraper(type)
CallableTree::Node::External::Builder
.new
...
.hookable
.build
end
...
module Logging
INDENT_SIZE = 2
BLANK = ' '
LIST_STYLE = '*'
INPUT_LABEL = 'Input :'
OUTPUT_LABEL = 'Output:'
def self.loggable(node)
node.after_matcher! do |matched, _node_:, **|
prefix = LIST_STYLE.rjust((_node_.depth * INDENT_SIZE) - INDENT_SIZE + LIST_STYLE.length, BLANK)
puts "#{prefix} #{_node_.identity}: [matched: #{matched}]"
matched
end
return unless node.external?
node
.before_caller! do |input, *, _node_:, **|
input_prefix = INPUT_LABEL.rjust((_node_.depth * INDENT_SIZE) + INPUT_LABEL.length, BLANK)
puts "#{input_prefix} #{input}"
input
end
.after_caller! do |output, _node_:, **|
output_prefix = OUTPUT_LABEL.rjust((_node_.depth * INDENT_SIZE) + OUTPUT_LABEL.length, BLANK)
puts "#{output_prefix} #{output}"
output
end
end
end
loggable = Logging.method(:loggable)
tree = CallableTree::Node::Root.new.seekable.append(
JSONParser.new.tap(&loggable).seekable.append(
AnimalsJSONScraper.new.tap(&loggable).verbosify,
FruitsJSONScraper.new.tap(&loggable).verbosify
),
XMLParser.new.tap(&loggable).seekable.append(
AnimalsXMLScraper.new.tap(&loggable).verbosify,
FruitsXMLScraper.new.tap(&loggable).verbosify
)
)
...Also, see examples/builder/hooks.rb for detail about CallableTree::Node::Hooks::*.
Run examples/builder/logging.rb:
% ruby examples/builder/logging.rb
* JSONParser: [matched: true]
* AnimalsJSONScraper: [matched: true]
Input : {"animals"=>[{"name"=>"Dog", "emoji"=>"πΆ"}, {"name"=>"Cat", "emoji"=>"π±"}]}
Output: {"Dog"=>"πΆ", "Cat"=>"π±"}
#<struct CallableTree::Node::External::Output
value={"Dog"=>"πΆ", "Cat"=>"π±"},
options={:foo=>:bar},
routes=[AnimalsJSONScraper, JSONParser, CallableTree::Node::Root]>
---
* JSONParser: [matched: false]
* XMLParser: [matched: true]
* AnimalsXMLScraper: [matched: true]
Input : <root><animals><animal emoji='πΆ' name='Dog'/><animal emoji='π±' name='Cat'/></animals></root>
Output: {"Dog"=>"πΆ", "Cat"=>"π±"}
#<struct CallableTree::Node::External::Output
value={"Dog"=>"πΆ", "Cat"=>"π±"},
options={:foo=>:bar},
routes=[AnimalsXMLScraper, XMLParser, CallableTree::Node::Root]>
---
* JSONParser: [matched: true]
* AnimalsJSONScraper: [matched: false]
* FruitsJSONScraper: [matched: true]
Input : {"fruits"=>[{"name"=>"Red Apple", "emoji"=>"π"}, {"name"=>"Green Apple", "emoji"=>"π"}]}
Output: {"Red Apple"=>"π", "Green Apple"=>"π"}
#<struct CallableTree::Node::External::Output
value={"Red Apple"=>"π", "Green Apple"=>"π"},
options={:foo=>:bar},
routes=[FruitsJSONScraper, JSONParser, CallableTree::Node::Root]>
---
* JSONParser: [matched: false]
* XMLParser: [matched: true]
* AnimalsXMLScraper: [matched: false]
* FruitsXMLScraper: [matched: true]
Input : <root><fruits><fruit emoji='π' name='Red Apple'/><fruit emoji='π' name='Green Apple'/></fruits></root>
Output: {"Red Apple"=>"π", "Green Apple"=>"π"}
#<struct CallableTree::Node::External::Output
value={"Red Apple"=>"π", "Green Apple"=>"π"},
options={:foo=>:bar},
routes=[FruitsXMLScraper, XMLParser, CallableTree::Node::Root]>Specify an identifier to customize the node identity.
examples/builder/identity.rb:
JSONParser =
CallableTree::Node::Internal::Builder
.new
...
.identifier { |_node_:| _node_.object_id }
.build
XMLParser =
CallableTree::Node::Internal::Builder
.new
...
.identifier { |_node_:| _node_.object_id }
.build
def build_json_scraper(type)
CallableTree::Node::External::Builder
.new
...
.identifier { |_node_:| _node_.object_id }
.build
end
...
def build_xml_scraper(type)
CallableTree::Node::External::Builder
.new
...
.identifier { |_node_:| _node_.object_id }
.build
end
...Run examples/builder/identity.rb:
% ruby examples/builder/identity.rb
#<struct CallableTree::Node::External::Output
value={"Dog"=>"πΆ", "Cat"=>"π±"},
options={:foo=>:bar},
routes=[60, 80, CallableTree::Node::Root]>
---
#<struct CallableTree::Node::External::Output
value={"Dog"=>"πΆ", "Cat"=>"π±"},
options={:foo=>:bar},
routes=[220, 240, CallableTree::Node::Root]>
---
#<struct CallableTree::Node::External::Output
value={"Red Apple"=>"π", "Green Apple"=>"π"},
options={:foo=>:bar},
routes=[260, 80, CallableTree::Node::Root]>
---
#<struct CallableTree::Node::External::Output
value={"Red Apple"=>"π", "Green Apple"=>"π"},
options={:foo=>:bar},
routes=[400, 240, CallableTree::Node::Root]>
---Bug reports and pull requests are welcome on GitHub at https://github.com/jsmmr/ruby_callable_tree.
The gem is available as open source under the terms of the MIT License.