|
3 | 3 | [](https://github.com/jsmmr/ruby_callable_tree/actions/workflows/build.yml) |
4 | 4 | [](https://github.com/jsmmr/ruby_callable_tree/actions/workflows/codeql-analysis.yml) |
5 | 5 |
|
| 6 | +A framework for building tree-structured executable workflows in Ruby. |
| 7 | + |
| 8 | +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. |
| 9 | + |
6 | 10 | ## Installation |
7 | 11 |
|
8 | 12 | Add this line to your application's Gemfile: |
@@ -32,7 +36,7 @@ Builds a tree of `CallableTree` nodes. Invokes the `call` method on nodes where |
32 | 36 |
|
33 | 37 | ### Basic |
34 | 38 |
|
35 | | -There are two ways to define the nodes: class style and builder style. |
| 39 | +There are three ways to define nodes: class style, builder style, and factory style. |
36 | 40 |
|
37 | 41 | #### `CallableTree::Node::Internal#seekable` (default strategy) |
38 | 42 |
|
@@ -257,6 +261,84 @@ Run `examples/builder/internal-seekable.rb`: |
257 | 261 | --- |
258 | 262 | ``` |
259 | 263 |
|
| 264 | +##### Factory style |
| 265 | + |
| 266 | +Factory style defines behaviors as procs first, then assembles the tree structure separately. This makes the tree structure clearly visible. |
| 267 | + |
| 268 | +`examples/factory/internal-seekable.rb`: |
| 269 | +```ruby |
| 270 | +# === Behavior Definitions === |
| 271 | + |
| 272 | +json_matcher = ->(input, **) { File.extname(input) == '.json' } |
| 273 | +json_caller = lambda do |input, **options, &original| |
| 274 | + File.open(input) do |file| |
| 275 | + json = JSON.parse(file.read) |
| 276 | + original.call(json, **options) |
| 277 | + end |
| 278 | +end |
| 279 | + |
| 280 | +xml_matcher = ->(input, **) { File.extname(input) == '.xml' } |
| 281 | +xml_caller = lambda do |input, **options, &original| |
| 282 | + File.open(input) do |file| |
| 283 | + original.call(REXML::Document.new(file), **options) |
| 284 | + end |
| 285 | +end |
| 286 | + |
| 287 | +animals_json_matcher = ->(input, **) { !input['animals'].nil? } |
| 288 | +animals_json_caller = ->(input, **) { input['animals'].to_h { |e| [e['name'], e['emoji']] } } |
| 289 | + |
| 290 | +fruits_json_matcher = ->(input, **) { !input['fruits'].nil? } |
| 291 | +fruits_json_caller = ->(input, **) { input['fruits'].to_h { |e| [e['name'], e['emoji']] } } |
| 292 | + |
| 293 | +animals_xml_matcher = ->(input, **) { !input.get_elements('//animals').empty? } |
| 294 | +animals_xml_caller = ->(input, **) { input.get_elements('//animals').first.to_h { |e| [e['name'], e['emoji']] } } |
| 295 | + |
| 296 | +fruits_xml_matcher = ->(input, **) { !input.get_elements('//fruits').empty? } |
| 297 | +fruits_xml_caller = ->(input, **) { input.get_elements('//fruits').first.to_h { |e| [e['name'], e['emoji']] } } |
| 298 | + |
| 299 | +terminator_true = ->(*) { true } |
| 300 | + |
| 301 | +# === Tree Structure (clearly visible!) === |
| 302 | + |
| 303 | +tree = CallableTree::Node::Root.new.seekable.append( |
| 304 | + CallableTree::Node::Internal.create( |
| 305 | + matcher: json_matcher, |
| 306 | + caller: json_caller, |
| 307 | + terminator: terminator_true |
| 308 | + ).seekable.append( |
| 309 | + CallableTree::Node::External.create(matcher: animals_json_matcher, caller: animals_json_caller), |
| 310 | + CallableTree::Node::External.create(matcher: fruits_json_matcher, caller: fruits_json_caller) |
| 311 | + ), |
| 312 | + CallableTree::Node::Internal.create( |
| 313 | + matcher: xml_matcher, |
| 314 | + caller: xml_caller, |
| 315 | + terminator: terminator_true |
| 316 | + ).seekable.append( |
| 317 | + CallableTree::Node::External.create(matcher: animals_xml_matcher, caller: animals_xml_caller), |
| 318 | + CallableTree::Node::External.create(matcher: fruits_xml_matcher, caller: fruits_xml_caller) |
| 319 | + ) |
| 320 | +) |
| 321 | + |
| 322 | +Dir.glob("#{__dir__}/../docs/*") do |file| |
| 323 | + options = { foo: :bar } |
| 324 | + pp tree.call(file, **options) |
| 325 | + puts '---' |
| 326 | +end |
| 327 | +``` |
| 328 | + |
| 329 | +Run `examples/factory/internal-seekable.rb`: |
| 330 | +```sh |
| 331 | +% ruby examples/factory/internal-seekable.rb |
| 332 | +{"Dog"=>"🐶", "Cat"=>"🐱"} |
| 333 | +--- |
| 334 | +{"Dog"=>"🐶", "Cat"=>"🐱"} |
| 335 | +--- |
| 336 | +{"Red Apple"=>"🍎", "Green Apple"=>"🍏"} |
| 337 | +--- |
| 338 | +{"Red Apple"=>"🍎", "Green Apple"=>"🍏"} |
| 339 | +--- |
| 340 | +``` |
| 341 | + |
260 | 342 | #### `CallableTree::Node::Internal#broadcastable` |
261 | 343 |
|
262 | 344 | This strategy broadcasts input to all child nodes and returns their results as an array. It ignores child `terminate?` methods by default. |
@@ -403,6 +485,55 @@ Run `examples/builder/internal-broadcastable.rb`: |
403 | 485 | 10 -> [nil, nil] |
404 | 486 | ``` |
405 | 487 |
|
| 488 | +##### Factory style |
| 489 | +
|
| 490 | +`examples/factory/internal-broadcastable.rb`: |
| 491 | +```ruby |
| 492 | +# === Behavior Definitions === |
| 493 | + |
| 494 | +less_than_5_matcher = ->(input, **, &original) { original.call(input) && input < 5 } |
| 495 | +less_than_10_matcher = ->(input, **, &original) { original.call(input) && input < 10 } |
| 496 | + |
| 497 | +multiply_2_caller = ->(input, **) { input * 2 } |
| 498 | +add_1_caller = ->(input, **) { input + 1 } |
| 499 | +multiply_3_caller = ->(input, **) { input * 3 } |
| 500 | +subtract_1_caller = ->(input, **) { input - 1 } |
| 501 | + |
| 502 | +# === Tree Structure === |
| 503 | + |
| 504 | +tree = CallableTree::Node::Root.new.broadcastable.append( |
| 505 | + CallableTree::Node::Internal.create(matcher: less_than_5_matcher).broadcastable.append( |
| 506 | + CallableTree::Node::External.create(caller: multiply_2_caller), |
| 507 | + CallableTree::Node::External.create(caller: add_1_caller) |
| 508 | + ), |
| 509 | + CallableTree::Node::Internal.create(matcher: less_than_10_matcher).broadcastable.append( |
| 510 | + CallableTree::Node::External.create(caller: multiply_3_caller), |
| 511 | + CallableTree::Node::External.create(caller: subtract_1_caller) |
| 512 | + ) |
| 513 | +) |
| 514 | + |
| 515 | +(0..10).each do |input| |
| 516 | + output = tree.call(input) |
| 517 | + puts "#{input} -> #{output}" |
| 518 | +end |
| 519 | +``` |
| 520 | +
|
| 521 | +Run `examples/factory/internal-broadcastable.rb`: |
| 522 | +```sh |
| 523 | +% ruby examples/factory/internal-broadcastable.rb |
| 524 | +0 -> [[0, 1], [0, -1]] |
| 525 | +1 -> [[2, 2], [3, 0]] |
| 526 | +2 -> [[4, 3], [6, 1]] |
| 527 | +3 -> [[6, 4], [9, 2]] |
| 528 | +4 -> [[8, 5], [12, 3]] |
| 529 | +5 -> [nil, [15, 4]] |
| 530 | +6 -> [nil, [18, 5]] |
| 531 | +7 -> [nil, [21, 6]] |
| 532 | +8 -> [nil, [24, 7]] |
| 533 | +9 -> [nil, [27, 8]] |
| 534 | +10 -> [nil, nil] |
| 535 | +``` |
| 536 | +
|
406 | 537 | #### `CallableTree::Node::Internal#composable` |
407 | 538 |
|
408 | 539 | This strategy chains child nodes, passing the output of the previous node as input to the next. |
@@ -550,6 +681,55 @@ Run `examples/builder/internal-composable.rb`: |
550 | 681 | 10 -> 10 |
551 | 682 | ``` |
552 | 683 |
|
| 684 | +##### Factory style |
| 685 | +
|
| 686 | +`examples/factory/internal-composable.rb`: |
| 687 | +```ruby |
| 688 | +# === Behavior Definitions === |
| 689 | + |
| 690 | +less_than_5_matcher = ->(input, **, &original) { original.call(input) && input < 5 } |
| 691 | +less_than_10_matcher = ->(input, **, &original) { original.call(input) && input < 10 } |
| 692 | + |
| 693 | +multiply_2_caller = ->(input, **) { input * 2 } |
| 694 | +add_1_caller = ->(input, **) { input + 1 } |
| 695 | +multiply_3_caller = ->(input, **) { input * 3 } |
| 696 | +subtract_1_caller = ->(input, **) { input - 1 } |
| 697 | + |
| 698 | +# === Tree Structure === |
| 699 | + |
| 700 | +tree = CallableTree::Node::Root.new.composable.append( |
| 701 | + CallableTree::Node::Internal.create(matcher: less_than_5_matcher).composable.append( |
| 702 | + CallableTree::Node::External.create(caller: multiply_2_caller), |
| 703 | + CallableTree::Node::External.create(caller: add_1_caller) |
| 704 | + ), |
| 705 | + CallableTree::Node::Internal.create(matcher: less_than_10_matcher).composable.append( |
| 706 | + CallableTree::Node::External.create(caller: multiply_3_caller), |
| 707 | + CallableTree::Node::External.create(caller: subtract_1_caller) |
| 708 | + ) |
| 709 | +) |
| 710 | + |
| 711 | +(0..10).each do |input| |
| 712 | + output = tree.call(input) |
| 713 | + puts "#{input} -> #{output}" |
| 714 | +end |
| 715 | +``` |
| 716 | +
|
| 717 | +Run `examples/factory/internal-composable.rb`: |
| 718 | +```sh |
| 719 | +% ruby examples/factory/internal-composable.rb |
| 720 | +0 -> 2 |
| 721 | +1 -> 8 |
| 722 | +2 -> 14 |
| 723 | +3 -> 20 |
| 724 | +4 -> 26 |
| 725 | +5 -> 14 |
| 726 | +6 -> 17 |
| 727 | +7 -> 20 |
| 728 | +8 -> 23 |
| 729 | +9 -> 26 |
| 730 | +10 -> 10 |
| 731 | +``` |
| 732 | +
|
553 | 733 | ### Advanced |
554 | 734 |
|
555 | 735 | #### `CallableTree::Node::External#verbosify` |
|
0 commit comments