rooibos 0.6.2 → 0.7.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.
Files changed (217) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSES/BSD-2-Clause.txt +9 -0
  3. data/REUSE.toml +5 -0
  4. data/exe/.gitkeep +0 -0
  5. data/lib/rooibos/cli/commands/new.rb +24 -0
  6. data/lib/rooibos/command/batch.rb +10 -0
  7. data/lib/rooibos/command/bubble.rb +34 -0
  8. data/lib/rooibos/command/custom.rb +3 -2
  9. data/lib/rooibos/command/deliver.rb +50 -0
  10. data/lib/rooibos/command/http.rb +1 -1
  11. data/lib/rooibos/command/lifecycle.rb +3 -1
  12. data/lib/rooibos/command/outlet.rb +19 -9
  13. data/lib/rooibos/command.rb +107 -3
  14. data/lib/rooibos/configuration.rb +29 -0
  15. data/lib/rooibos/message/bubbled.rb +29 -0
  16. data/lib/rooibos/message.rb +24 -6
  17. data/lib/rooibos/router/action.rb +36 -0
  18. data/lib/rooibos/router/flow/dispatch.rb +39 -0
  19. data/lib/rooibos/router/flow/inward.rb +41 -0
  20. data/lib/rooibos/router/flow/outward.rb +44 -0
  21. data/lib/rooibos/router/guard.rb +56 -0
  22. data/lib/rooibos/router/predicate.rb +65 -0
  23. data/lib/rooibos/router/registry/actions.rb +41 -0
  24. data/lib/rooibos/router/registry/forwards.rb +58 -0
  25. data/lib/rooibos/router/registry/observes.rb +57 -0
  26. data/lib/rooibos/router/registry/otherwises.rb +29 -0
  27. data/lib/rooibos/router/registry/receives.rb +57 -0
  28. data/lib/rooibos/router/registry/routes.rb +59 -0
  29. data/lib/rooibos/router/registry.rb +26 -0
  30. data/lib/rooibos/router/route.rb +42 -0
  31. data/lib/rooibos/router/router_update.rb +53 -0
  32. data/lib/rooibos/router/rule/forward.rb +39 -0
  33. data/lib/rooibos/router/rule/observe.rb +22 -0
  34. data/lib/rooibos/router/rule/otherwise.rb +26 -0
  35. data/lib/rooibos/router/rule/receive.rb +22 -0
  36. data/lib/rooibos/router/rule.rb +40 -0
  37. data/lib/rooibos/router.rb +424 -438
  38. data/lib/rooibos/runtime.rb +37 -52
  39. data/lib/rooibos/test_helper.rb +22 -0
  40. data/lib/rooibos/transition.rb +92 -0
  41. data/lib/rooibos/version.rb +1 -1
  42. data/lib/rooibos.rb +2 -57
  43. data/sig/rooibos/cli.rbs +1 -0
  44. data/sig/rooibos/command.rbs +44 -0
  45. data/sig/rooibos/configuration.rbs +20 -0
  46. data/sig/rooibos/message.rbs +12 -0
  47. data/sig/rooibos/router/action.rbs +33 -0
  48. data/sig/rooibos/router/actions.rbs +27 -0
  49. data/sig/rooibos/router/flow/dispatch.rbs +29 -0
  50. data/sig/rooibos/router/flow/inward.rbs +37 -0
  51. data/sig/rooibos/router/flow/outward.rbs +36 -0
  52. data/sig/rooibos/router/forward.rbs +35 -0
  53. data/sig/rooibos/router/forwards.rbs +34 -0
  54. data/sig/rooibos/router/guard.rbs +21 -0
  55. data/sig/rooibos/router/observe.rbs +20 -0
  56. data/sig/rooibos/router/observes.rbs +38 -0
  57. data/sig/rooibos/router/otherwise.rbs +22 -0
  58. data/sig/rooibos/router/otherwises.rbs +20 -0
  59. data/sig/rooibos/router/predicate.rbs +51 -0
  60. data/sig/rooibos/router/receive.rbs +20 -0
  61. data/sig/rooibos/router/receives.rbs +38 -0
  62. data/sig/rooibos/router/registry.rbs +24 -0
  63. data/sig/rooibos/router/route.rbs +46 -0
  64. data/sig/rooibos/router/router_update.rbs +33 -0
  65. data/sig/rooibos/router/routes.rbs +41 -0
  66. data/sig/rooibos/router/rule.rbs +36 -0
  67. data/sig/rooibos/router.rbs +216 -161
  68. data/sig/rooibos/runtime.rbs +0 -1
  69. data/sig/rooibos/test_helper.rbs +6 -0
  70. data/sig/rooibos/transition.rbs +33 -0
  71. data/sig/rooibos.rbs +0 -10
  72. metadata +144 -198
  73. data/.builds/ruby-3.2.yml +0 -55
  74. data/.builds/ruby-3.3.yml +0 -55
  75. data/.builds/ruby-3.4.yml +0 -55
  76. data/.builds/ruby-4.0.0.yml +0 -55
  77. data/.pre-commit-config.yaml +0 -16
  78. data/.rubocop.yml +0 -8
  79. data/AGENTS.md +0 -108
  80. data/CHANGELOG.md +0 -308
  81. data/README.md +0 -183
  82. data/README.rdoc +0 -374
  83. data/Rakefile +0 -16
  84. data/Steepfile +0 -13
  85. data/doc/best_practices/forms_and_validation.md +0 -20
  86. data/doc/best_practices/http_workflows.md +0 -20
  87. data/doc/best_practices/index.md +0 -26
  88. data/doc/best_practices/lists_and_tables.md +0 -20
  89. data/doc/best_practices/modal_dialogs.md +0 -20
  90. data/doc/best_practices/no_stateful_widgets.md +0 -184
  91. data/doc/best_practices/orchestration.md +0 -20
  92. data/doc/best_practices/streaming_data.md +0 -20
  93. data/doc/contributors/design/commands_and_outlets.md +0 -214
  94. data/doc/contributors/design/mvu_tea_implementations_research.md +0 -373
  95. data/doc/contributors/documentation_plan.md +0 -616
  96. data/doc/contributors/documentation_stub_audit.md +0 -112
  97. data/doc/contributors/documentation_style.md +0 -275
  98. data/doc/contributors/e2e_pty.md +0 -168
  99. data/doc/contributors/maybe_stateful_router.md +0 -56
  100. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +0 -70
  101. data/doc/contributors/specs/file_browser.md +0 -789
  102. data/doc/contributors/specs/file_browser_stories.md +0 -784
  103. data/doc/contributors/specs/tutorials_to_stories.rb +0 -167
  104. data/doc/contributors/todo/scrollbar.md +0 -118
  105. data/doc/contributors/tutorial_old/01_project_setup.md +0 -20
  106. data/doc/contributors/tutorial_old/02_hello_world.md +0 -24
  107. data/doc/contributors/tutorial_old/03_adding_state.md +0 -26
  108. data/doc/contributors/tutorial_old/06_organizing_your_code.md +0 -20
  109. data/doc/contributors/tutorial_old/07_your_first_command.md +0 -21
  110. data/doc/contributors/tutorial_old/08_the_preview_pane.md +0 -20
  111. data/doc/contributors/tutorial_old/09_loading_states.md +0 -20
  112. data/doc/contributors/tutorial_old/10_testing_your_app.md +0 -20
  113. data/doc/contributors/tutorial_old/11_polish_and_refine.md +0 -20
  114. data/doc/contributors/tutorial_old/12_going_further.md +0 -20
  115. data/doc/contributors/tutorial_old/index.md +0 -20
  116. data/doc/custom.css +0 -22
  117. data/doc/essentials/commands.md +0 -20
  118. data/doc/essentials/index.md +0 -31
  119. data/doc/essentials/messages.md +0 -21
  120. data/doc/essentials/models.md +0 -21
  121. data/doc/essentials/shortcuts.md +0 -19
  122. data/doc/essentials/the_elm_architecture.md +0 -24
  123. data/doc/essentials/the_runtime.md +0 -21
  124. data/doc/essentials/update_functions.md +0 -20
  125. data/doc/essentials/views.md +0 -22
  126. data/doc/getting_started/for_go_developers.md +0 -16
  127. data/doc/getting_started/for_python_developers.md +0 -16
  128. data/doc/getting_started/for_rails_developers.md +0 -17
  129. data/doc/getting_started/for_ratatui_ruby_developers.md +0 -17
  130. data/doc/getting_started/for_react_developers.md +0 -17
  131. data/doc/getting_started/index.md +0 -52
  132. data/doc/getting_started/install.md +0 -20
  133. data/doc/getting_started/quickstart.md +0 -20
  134. data/doc/getting_started/ruby_primer.md +0 -19
  135. data/doc/getting_started/why_rooibos.md +0 -20
  136. data/doc/images/verify_readme_usage.png +0 -0
  137. data/doc/images/widget_cmd_exec.png +0 -0
  138. data/doc/index.md +0 -93
  139. data/doc/scaling_up/async_patterns.md +0 -20
  140. data/doc/scaling_up/command_composition.md +0 -20
  141. data/doc/scaling_up/custom_commands.md +0 -21
  142. data/doc/scaling_up/fractal_architecture.md +0 -20
  143. data/doc/scaling_up/index.md +0 -30
  144. data/doc/scaling_up/message_routing.md +0 -20
  145. data/doc/scaling_up/ractor_safety.md +0 -20
  146. data/doc/scaling_up/testing.md +0 -21
  147. data/doc/troubleshooting/common_errors.md +0 -20
  148. data/doc/troubleshooting/debugging.md +0 -21
  149. data/doc/troubleshooting/index.md +0 -23
  150. data/doc/troubleshooting/performance.md +0 -20
  151. data/doc/tutorial/01_project_setup.md +0 -44
  152. data/doc/tutorial/02_hello_world.md +0 -45
  153. data/doc/tutorial/03_static_file_list.md +0 -44
  154. data/doc/tutorial/04_arrow_navigation.md +0 -47
  155. data/doc/tutorial/05_real_files.md +0 -45
  156. data/doc/tutorial/06_safe_refactoring.md +0 -21
  157. data/doc/tutorial/07_red_first_tdd.md +0 -26
  158. data/doc/tutorial/08_file_metadata.md +0 -42
  159. data/doc/tutorial/09_text_preview.md +0 -44
  160. data/doc/tutorial/10_directory_tree.md +0 -42
  161. data/doc/tutorial/11_pane_focus.md +0 -40
  162. data/doc/tutorial/12_sorting.md +0 -41
  163. data/doc/tutorial/13_filtering.md +0 -43
  164. data/doc/tutorial/14_toggle_hidden.md +0 -41
  165. data/doc/tutorial/15_text_input_widget.md +0 -43
  166. data/doc/tutorial/16_rename_files.md +0 -42
  167. data/doc/tutorial/17_confirmation_dialogs.md +0 -43
  168. data/doc/tutorial/18_progress_indicators.md +0 -43
  169. data/doc/tutorial/19_atomic_operations.md +0 -42
  170. data/doc/tutorial/20_external_editor.md +0 -42
  171. data/doc/tutorial/21_modal_overlays.md +0 -41
  172. data/doc/tutorial/22_error_handling.md +0 -43
  173. data/doc/tutorial/23_terminal_capabilities.md +0 -53
  174. data/doc/tutorial/24_mouse_events.md +0 -43
  175. data/doc/tutorial/25_resize_events.md +0 -43
  176. data/doc/tutorial/26_loading_states.md +0 -42
  177. data/doc/tutorial/27_performance.md +0 -43
  178. data/doc/tutorial/28_color_schemes.md +0 -47
  179. data/doc/tutorial/29_configuration.md +0 -124
  180. data/doc/tutorial/30_going_further.md +0 -17
  181. data/doc/tutorial/index.md +0 -17
  182. data/examples/app_fractal_dashboard/README.md +0 -60
  183. data/examples/app_fractal_dashboard/app.rb +0 -63
  184. data/examples/app_fractal_dashboard/dashboard/base.rb +0 -73
  185. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +0 -86
  186. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +0 -87
  187. data/examples/app_fractal_dashboard/dashboard/update_router.rb +0 -43
  188. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +0 -81
  189. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +0 -82
  190. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +0 -90
  191. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +0 -47
  192. data/examples/app_fractal_dashboard/fragments/network_panel.rb +0 -45
  193. data/examples/app_fractal_dashboard/fragments/ping.rb +0 -47
  194. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +0 -45
  195. data/examples/app_fractal_dashboard/fragments/system_info.rb +0 -47
  196. data/examples/app_fractal_dashboard/fragments/uptime.rb +0 -47
  197. data/examples/tutorial/01/app.rb +0 -50
  198. data/examples/tutorial/02/app.rb +0 -64
  199. data/examples/tutorial/03/app.rb +0 -91
  200. data/examples/tutorial/06_safe_refactoring/app.rb +0 -124
  201. data/examples/verify_readme_usage/README.md +0 -54
  202. data/examples/verify_readme_usage/app.rb +0 -47
  203. data/examples/verify_website_first_app/app.rb +0 -85
  204. data/examples/verify_website_hello_mvu/app.rb +0 -31
  205. data/examples/widget_command_system/README.md +0 -70
  206. data/examples/widget_command_system/app.rb +0 -134
  207. data/generate_tutorial_stubs.rb +0 -126
  208. data/mise.toml +0 -8
  209. data/rbs_collection.lock.yaml +0 -108
  210. data/rbs_collection.yaml +0 -15
  211. data/tasks/example_viewer.html.erb +0 -172
  212. data/tasks/install.rake +0 -29
  213. data/tasks/resources/build.yml.erb +0 -55
  214. data/tasks/resources/index.html.erb +0 -44
  215. data/tasks/resources/rubies.yml +0 -7
  216. data/tasks/steep.rake +0 -11
  217. /data/{vendor/goodcop/base.yml → lib/rooibos/rubocop.yml} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0bd248d3b6933a881c6a2dc2e01eff42ecf20ae9b8547237f216de1ba3fcc54
4
- data.tar.gz: ef8473cff78d86f01022ae554b9a60bf7fdadbfd8528d499cd59ccc592ce5a2a
3
+ metadata.gz: de0fd230ae66b1f7bb75d7bc0dbaad374d929efe3102638f0dfd2a7558312bc5
4
+ data.tar.gz: 03bc89ff9e8880b053e022dfdf8bbab94f107e153e174402d9ce5c83b9644c2f
5
5
  SHA512:
6
- metadata.gz: e3dbe609e9423b3e7e9335c72da14e3eca798d4083baf6f7c17761eeb7f42267bd68d391e6e0102d225f9c3f7e4fc1799221547440baa2d5f9c28beed96ac5b8
7
- data.tar.gz: 49d737453d32dc06193baf1b529270938ae8861afcf9f2e5c1faef0a6a04b594d2cd8d63dfb4892c51b2b64b5e06c52c3dbbb260daaa7184a961d0969ca26b39
6
+ metadata.gz: de7f4335062b9feefca507ef70b058d1561efe6d3fd69ccf764c2b1e4809d17bd3378380b752cf5c1108957dc7ca94a249b276fe1058c1fe6d9abbecc8d1f060
7
+ data.tar.gz: b50d6ddb3cc4ab37d0d2514d3852c95dbedd6c4cb756cbcb7fe7fcf8937189e7137961704bc1a284a9777f5ee83bd8fe263f9bad99e71f45ea5e29d1da6c864e
@@ -0,0 +1,9 @@
1
+ Copyright (c) <year> <owner>
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/REUSE.toml CHANGED
@@ -27,3 +27,8 @@ SPDX-License-Identifier = "LGPL-3.0-or-later"
27
27
  path = 'doc/images/*'
28
28
  SPDX-FileCopyrightText = "2026 Kerrick Long <me@kerricklong.com>"
29
29
  SPDX-License-Identifier = "CC-BY-SA-4.0"
30
+
31
+ [[annotations]]
32
+ path = 'examples/tutorial/**'
33
+ SPDX-FileCopyrightText = "2026 Kerrick Long <me@kerricklong.com>"
34
+ SPDX-License-Identifier = "MIT-0"
data/exe/.gitkeep ADDED
File without changes
@@ -236,6 +236,13 @@ module Rooibos
236
236
  end
237
237
  end
238
238
 
239
+ # Overwrite bundle gem's .rubocop.yml with Rooibos config
240
+ rubocop_file = app_path / ".rubocop.yml"
241
+ if rubocop_file.exist?
242
+ File.write(rubocop_file.to_s, rubocop_template)
243
+ puts "Updated #{rubocop_file}"
244
+ end
245
+
239
246
  # Make initial git commit if git is enabled and bundle didn't
240
247
  if git_enabled?(passthrough_args)
241
248
  make_initial_commit(app_path)
@@ -367,6 +374,23 @@ module Rooibos
367
374
  RUBY
368
375
  end
369
376
  private_class_method :test_template
377
+
378
+ def self.rubocop_template
379
+ <<~YAML
380
+ inherit_gem:
381
+ rooibos: lib/rooibos/rubocop.yml
382
+
383
+ AllCops:
384
+ TargetRubyVersion: 3.2
385
+
386
+ Style/StringLiterals:
387
+ EnforcedStyle: double_quotes
388
+
389
+ Style/StringLiteralsInInterpolation:
390
+ EnforcedStyle: double_quotes
391
+ YAML
392
+ end
393
+ private_class_method :rubocop_template
370
394
  end
371
395
  end
372
396
  end
@@ -98,6 +98,16 @@ module Rooibos
98
98
  out.put(Message::Batch.new(command: self))
99
99
  end
100
100
  end
101
+
102
+ def extract_bubbles # :nodoc:
103
+ bubbles, rest = commands.partition { |c| c.is_a?(Bubble) }
104
+ remaining = case rest.size
105
+ when 0 then nil
106
+ when 1 then rest.first
107
+ else Batch.new(*rest)
108
+ end
109
+ [bubbles, remaining]
110
+ end
101
111
  end
102
112
  end
103
113
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Command
10
+ # Carries a message outward through the fragment hierarchy.
11
+ #
12
+ # Nested fragments produce signals. Outer fragments consume them. Passing
13
+ # callbacks down the tree couples fragments tightly. Direct references
14
+ # make reuse difficult.
15
+ #
16
+ # This command wraps a message for bubbling. Outer fragments intercept it
17
+ # and decide how to handle it. With the Router DSL, use <tt>observe</tt>
18
+ # or <tt>intercept</tt>. Without the Router, check for <tt>Command::Bubble</tt>
19
+ # manually and extract the message. Unhandled bubbles can be re-returned
20
+ # to continue propagation outward.
21
+ #
22
+ # The runtime does not execute this command. Outer fragments handle it.
23
+ # Calling <tt>call</tt> raises an error.
24
+ #
25
+ # [message] The payload to propagate outward.
26
+ class Bubble < Data.define(:message)
27
+ include Custom
28
+
29
+ # No-op: unhandled bubbles that escape the Router hierarchy( when no
30
+ # fragment intercepts the message) are silently dropped.
31
+ def call(_out, _token) = nil
32
+ end
33
+ end
34
+ end
@@ -69,7 +69,8 @@ module Rooibos
69
69
  #
70
70
  # When the runtime cancels your command (app exit, navigation, explicit cancel),
71
71
  # it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
72
- # If your command does not exit within this window, it is force-killed.
72
+ # If your command does not exit within this window, it is orphaned until
73
+ # process exit. There is no safe way to force-kill a Ruby thread.
73
74
  #
74
75
  # *This is NOT a lifetime limit.* Your command runs indefinitely until canceled.
75
76
  # A WebSocket open for 15 minutes is fine. This timeout only applies to the
@@ -80,7 +81,7 @@ module Rooibos
80
81
  # - <tt>0.5</tt> — Quick HTTP abort, no cleanup needed
81
82
  # - <tt>2.0</tt> — Default, suitable for most commands
82
83
  # - <tt>5.0</tt> — WebSocket close handshake with remote server
83
- # - <tt>Float::INFINITY</tt> — Never force-kill (database transactions)
84
+ # - <tt>Float::INFINITY</tt> — Wait indefinitely for cooperative exit (database transactions)
84
85
  #
85
86
  # === Example
86
87
  #
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Command
10
+ # Delivers a message to Update.
11
+ #
12
+ # Sometimes you have data ready now. A synchronous calculation. A value from
13
+ # the model. You want Update to process it as a message — pattern match on it,
14
+ # use predicates, the whole workflow.
15
+ #
16
+ # This command wraps any message and delivers it to Update via the runtime.
17
+ #
18
+ # Use it to send structured messages from Update, or to produce messages
19
+ # from synchronous operations.
20
+ #
21
+ # === Example
22
+ #
23
+ # Define a message type:
24
+ # class CacheLoaded < Data.define(:envelope, :data)
25
+ # include Rooibos::Message::Predicates
26
+ # end
27
+ #
28
+ # Send from Update:
29
+ # in { type: :key, code: "f" }
30
+ # cache = load_yaml_cache("auth")
31
+ # Command.deliver(CacheLoaded.new(envelope: :auth, data: cache))
32
+ #
33
+ # Receive in Update:
34
+ # in { type: :cache_loaded, envelope: :auth, data: }
35
+ # model.with(auth: data)
36
+ #
37
+ # Or use predicates:
38
+ # elsif message.cache_loaded? and message.auth
39
+ # model.with(auth: message.data)
40
+ #
41
+ class Deliver < Data.define(:message)
42
+ include Custom
43
+
44
+ # Sends the message to the runtime.
45
+ def call(out, _token)
46
+ out.put(message)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -139,7 +139,7 @@ module Rooibos
139
139
  end
140
140
 
141
141
  # Net::HTTP is blocking; no cooperative cancellation possible.
142
- # Grace period = 0 means runtime can force-kill immediately.
142
+ # Grace period = 0 means runtime will orphan the blocked thread immediately.
143
143
  def rooibos_cancellation_grace_period = 0
144
144
 
145
145
  def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords) # :nodoc:
@@ -33,7 +33,7 @@ module Rooibos
33
33
  # Runs a command synchronously, returning its result.
34
34
  #
35
35
  # Spawns a thread, races the result against cancellation and timeout.
36
- # On cancellation, waits the grace period then kills the thread if needed.
36
+ # On cancellation, waits the grace period then orphans the thread if needed.
37
37
  #
38
38
  # [command] Callable with <tt>call(out, token)</tt>.
39
39
  # [token] Parent's cancellation token.
@@ -89,6 +89,8 @@ module Rooibos
89
89
  command.call(outlet, cancellation)
90
90
  rescue => e
91
91
  channel.push Message::Error.new(command:, exception: e)
92
+ ensure
93
+ outlet.wait # Don't resolve until children from standing complete
92
94
  end
93
95
 
94
96
  entry = Entry.new(future:, origin:)
@@ -97,19 +97,29 @@ module Rooibos
97
97
 
98
98
  # Sends a message to the runtime.
99
99
  #
100
- # Custom commands produce results. Those results feed back into your
101
- # update function. This method handles the wiring.
102
- #
103
- # Call with one argument to send it directly. Call with multiple
104
- # arguments and they arrive as an array.
100
+ # Custom commands produce results. Messages about those results feed back
101
+ # into your update function. This method handles the wiring.
105
102
  #
106
103
  # Use it for complex data flows or transports Rooibos doesn't ship with.
107
104
  #
108
- # === Example
105
+ # For structured data and to avoid NoMethodError, define a custom
106
+ # Message class with +envelope+ and domain-specific fields, and mix in
107
+ # <tt>Rooibos::Message::Predicates</tt>. This follows the same pattern as
108
+ # built-in Message types and RatatuiRuby events.
109
+ #
110
+ # === Structured Messages
111
+ #
112
+ # class UserFetched < Data.define(:envelope, :user)
113
+ # include Rooibos::Message::Predicates
114
+ # end
115
+ #
116
+ # out.put(UserFetched.new(envelope: :profile, user: alice))
117
+ #
118
+ # # Update can pattern match:
119
+ # # in { type: :user_fetched, envelope: :profile, user: }
109
120
  #
110
- # out.put(:done) # Update receives :done
111
- # out.put(current_user) # Update receives current_user
112
- # out.put(:user, alice) # Update receives [:user, alice]
121
+ # # Update can also use predicates:
122
+ # # message.user if message.user_fetched? and message.profile?
113
123
  #
114
124
  # Debug mode validates Ractor-shareability.
115
125
  def put(*args)
@@ -14,6 +14,8 @@ require_relative "command/batch"
14
14
  require_relative "command/all"
15
15
  require_relative "command/http"
16
16
  require_relative "command/open"
17
+ require_relative "command/deliver"
18
+ require_relative "command/bubble"
17
19
 
18
20
  module Rooibos
19
21
  # Commands represent side effects.
@@ -54,12 +56,39 @@ module Rooibos
54
56
  class Exit < Data.define
55
57
  include Custom
56
58
 
59
+ # Ruby 3.x does not auto-freeze zero-member Data.define instances,
60
+ # which prevents Ractor shareability. Explicit freeze is idempotent on 4.0+.
61
+ def initialize # :nodoc:
62
+ super
63
+ freeze
64
+ end
65
+
57
66
  # Stub - Exit is a sentinel handled by runtime before dispatch.
58
67
  def call(_out, _token)
59
68
  raise "Exit command should never be dispatched"
60
69
  end
61
70
  end
62
71
 
72
+ # Internal wrapper for multiple commands to be dispatched separately.
73
+ #
74
+ # Router DSL uses this to return multiple commands from observe + keymap
75
+ # without triggering Message::Batch. The runtime unwraps this and dispatches
76
+ # each command independently.
77
+ #
78
+ # Unlike Batch:
79
+ # - Does NOT send Message::Batch on completion
80
+ # - Each command runs and sends its own messages
81
+ # - Invisible to app developers
82
+ class Separate < Data.define(:commands) # :nodoc:
83
+ include Custom
84
+
85
+ # Stub - Separate is a sentinel unwrapped by runtime before dispatch.
86
+ def call(_out, _token)
87
+ raise "Separate command should never be dispatched directly"
88
+ end
89
+ end
90
+ private_constant :Separate
91
+
63
92
  # Creates a quit command.
64
93
  #
65
94
  # Returns a sentinel the runtime detects to terminate the application.
@@ -78,6 +107,76 @@ module Rooibos
78
107
  Exit.new
79
108
  end
80
109
 
110
+ # Delivers a message to Update.
111
+ #
112
+ # Custom commands produce results. Those results feed back into your update
113
+ # function. This factory method wraps a message in a command that delivers
114
+ # it when executed.
115
+ #
116
+ # === Example
117
+ #
118
+ # # Define a message type
119
+ # class FetchComplete < Data.define(:envelope, :data)
120
+ # include Rooibos::Message::Predicates
121
+ # end
122
+ #
123
+ # # Send after a synchronous operation
124
+ # result = fetch_data_sync()
125
+ # [model, Command.deliver(FetchComplete.new(envelope: :items, data: result))]
126
+ #
127
+ # # Receive in Update
128
+ # in { type: :fetch_complete, envelope: :items, data: }
129
+ # model.with(items: data)
130
+ def self.deliver(message)
131
+ Deliver.new(message:)
132
+ end
133
+
134
+ # Bubbles a message outward through the fragment hierarchy.
135
+ #
136
+ # Nested fragments produce results. Sometimes those results belong to an outer
137
+ # fragment. Passing callbacks or references inward couples fragments tightly.
138
+ # The hierarchy becomes rigid.
139
+ #
140
+ # This command wraps a message for outward propagation. Outer fragments
141
+ # intercept the bubble and decide how to handle it. With the Router DSL,
142
+ # use <tt>observe</tt> or <tt>intercept</tt>. Without the Router, check
143
+ # for <tt>Command::Bubble</tt> manually and extract the message.
144
+ #
145
+ # Use it for notifications, validation results, or any signal that flows
146
+ # from nested fragments to outer containers.
147
+ #
148
+ # === Example (Router DSL)
149
+ #
150
+ # # Nested fragment signals completion
151
+ # class TaskComplete < Data.define(:envelope, :task_id)
152
+ # include Rooibos::Message::Predicates
153
+ # end
154
+ #
155
+ # # Return from nested Update
156
+ # [model, Command.bubble(TaskComplete.new(envelope: :task, task_id: 42))]
157
+ #
158
+ # # Outer Router observes the bubble
159
+ # observe TaskComplete do |model, message|
160
+ # model.with(completed_tasks: model.completed_tasks + [message.task_id])
161
+ # end
162
+ #
163
+ # === Example (Manual Bubbling)
164
+ #
165
+ # # Outer Update handles bubbles without Router
166
+ # def self.handle_nested_result(cmd, model)
167
+ # return [model, nil] unless cmd.is_a?(Command::Bubble)
168
+ #
169
+ # case cmd.message
170
+ # when TaskComplete
171
+ # [model.with(completed_tasks: model.completed_tasks + [cmd.message.task_id]), nil]
172
+ # else
173
+ # [model, cmd] # Re-bubble outward
174
+ # end
175
+ # end
176
+ def self.bubble(message)
177
+ Bubble.new(message:)
178
+ end
179
+
81
180
  # Creates a fresh cancellation that never fires.
82
181
  #
83
182
  # Some I/O operations cannot be canceled mid-execution. Ruby's <tt>Net::HTTP</tt>
@@ -118,7 +217,6 @@ module Rooibos
118
217
  # [model, Cancel.new(handle: model.active_fetch)]
119
218
  class Cancel < Data.define(:handle)
120
219
  include Custom
121
- include Message::Predicates
122
220
 
123
221
  # Stub - Cancel is a sentinel handled by runtime before dispatch.
124
222
  def call(_out, _token)
@@ -214,7 +312,12 @@ module Rooibos
214
312
  end
215
313
 
216
314
  private def stream_execution(out, token)
217
- Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
315
+ # pgroup: true spawns the child in its own process group.
316
+ # On Linux, popen3(string) invokes /bin/sh which may fork (not exec),
317
+ # so killing just the shell PID leaves the child orphaned. Signaling
318
+ # the process group (-pid) ensures TERM reaches all descendants.
319
+ pgroup_opts = Gem.win_platform? ? {} : { pgroup: true }
320
+ Open3.popen3(command, **pgroup_opts) do |stdin, stdout, stderr, wait_thr|
218
321
  stdin.close
219
322
  pid = wait_thr.pid
220
323
 
@@ -225,7 +328,8 @@ module Rooibos
225
328
  # Check cancellation before blocking on IO.select
226
329
  if token.canceled? && wait_thr.alive?
227
330
  begin
228
- Process.kill("TERM", pid)
331
+ # On Unix, signal the process group; on Windows, kill the process directly
332
+ Process.kill("TERM", Gem.win_platform? ? pid : -pid)
229
333
  rescue Errno::ESRCH
230
334
  # Already dead
231
335
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ # Configuration represents the Mealy machine (state, input) pair.
10
+ # Immutable value object; use with_model to derive new configurations.
11
+ class Configuration < Data.define(:message, :model)
12
+ ##
13
+ # Derives a new Configuration with an updated model.
14
+ # Returns <tt>self</tt> if <tt>new_model</tt> is <tt>nil</tt>.
15
+ def with_model(new_model)
16
+ return self unless new_model
17
+
18
+ Configuration.new(message:, model: new_model)
19
+ end
20
+
21
+ ##
22
+ # Destructures into <tt>[message, model]</tt> array.
23
+ def to_a
24
+ [message, model]
25
+ end
26
+ alias to_ary to_a
27
+ alias deconstruct to_a
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module Rooibos
9
+ module Message
10
+ # Message synthesized by Router when a child fragment bubbles.
11
+ #
12
+ # When a child fragment returns <tt>Command.bubble(message)</tt>, the Router
13
+ # wraps the inner message in Bubbled and dispatches it through the
14
+ # outward flow (observe, then intercept).
15
+ #
16
+ # [message] The inner message that was bubbled.
17
+ Bubbled = Data.define(:message) do
18
+ include Predicates
19
+
20
+ def deconstruct_keys(_keys)
21
+ { type: :bubbled, message: }
22
+ end
23
+
24
+ def bubbled?
25
+ true
26
+ end
27
+ end
28
+ end
29
+ end
@@ -68,11 +68,17 @@ module Rooibos
68
68
  end
69
69
  end
70
70
 
71
- # Returns <tt>false</tt> for unknown predicate methods.
71
+ # Returns <tt>true</tt> if predicate matches <tt>:type</tt> or
72
+ # <tt>:envelope</tt> from <tt>deconstruct_keys</tt>. Returns
73
+ # <tt>false</tt> for unknown predicate methods.
72
74
  def method_missing(name, *args, **kwargs, &block)
73
- return false if name.to_s.end_with?("?") && args.empty? && kwargs.empty?
74
-
75
- super
75
+ if name.to_s.end_with?("?") && args.empty? && kwargs.empty?
76
+ predicate = name.to_s.chomp("?").to_sym
77
+ keys = deconstruct_keys(nil)
78
+ keys[:type] == predicate || keys[:envelope] == predicate
79
+ else
80
+ super
81
+ end
76
82
  end
77
83
 
78
84
  # Fallback pattern matching for classes without explicit deconstruct_keys.
@@ -89,14 +95,25 @@ module Rooibos
89
95
  # msg = MyCustomMessage.new
90
96
  # msg.deconstruct_keys(nil) # => { type: :my_custom_message }
91
97
  # msg.to_sym # => :message_my_custom_message
92
- def deconstruct_keys(_keys)
98
+ def deconstruct_keys(keys)
93
99
  class_name = self.class.name&.split("::")&.last
94
100
  type_name = if class_name
95
101
  class_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
96
102
  else
97
103
  :custom
98
104
  end
99
- { type: type_name }
105
+
106
+ # Filter out :type before calling super — Data returns {} if any
107
+ # requested key is unknown, which breaks pattern matching
108
+ filtered_keys = keys&.reject { |k| k == :type }
109
+
110
+ # Preserve parent's fields (e.g., Data.define members) and add :type
111
+ parent_keys = begin
112
+ super(filtered_keys)
113
+ rescue NoMethodError
114
+ {} #: Hash[Symbol, untyped]
115
+ end
116
+ parent_keys.merge(type: type_name)
100
117
  end
101
118
 
102
119
  # Responds to all predicate methods.
@@ -117,3 +134,4 @@ require_relative "message/batch"
117
134
  require_relative "message/error"
118
135
  require_relative "message/canceled"
119
136
  require_relative "message/routed"
137
+ require_relative "message/bubbled"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Lambda action - calls handler directly with (message, model).
13
+ class LambdaAction < Data.define(:name, :handler)
14
+ def routed? = false
15
+
16
+ def apply(message, model, _routes)
17
+ result = handler.arity.zero? ? handler.call : handler.call(message, model)
18
+ Transition.from(result, model)
19
+ end
20
+ end
21
+ private_constant :LambdaAction
22
+
23
+ # Routed action - wraps message and delegates through route to fragment.
24
+ class RoutedAction < Data.define(:name, :fragment)
25
+ def routed? = true
26
+
27
+ def apply(message, model, routes)
28
+ route = routes.find { |r| r.fragment == fragment }
29
+ raise ArgumentError, "No route found for fragment #{fragment}" unless route
30
+ routed_message = Message::Routed.new(envelope: name, event: message)
31
+ route.delegate(routed_message, model)
32
+ end
33
+ end
34
+ private_constant :RoutedAction
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ module Flow
13
+ module Dispatch
14
+ private def run_all(rules, message, model)
15
+ transition = Transition.initial(model)
16
+ rules.each do |rule|
17
+ config = Configuration.new(message:, model: transition.model)
18
+ new_transition = rule.apply_if_matches(config, routes) or next
19
+ transition = separate_commands(transition, new_transition)
20
+ end
21
+ transition
22
+ end
23
+
24
+ private def apply_first_matching(rules, config, transition)
25
+ rules.each do |rule|
26
+ new_transition = rule.apply_if_matches(config, routes) or next
27
+ return separate_commands(transition, new_transition)
28
+ end
29
+ nil
30
+ end
31
+
32
+ private def separate_commands(transition, new_transition) = transition
33
+ .with_model(new_transition.model)
34
+ .with_separate_command(new_transition.command)
35
+ end
36
+ end
37
+ private_constant :Flow
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ module Flow
13
+ # Inward flow: messages traveling toward the leaves.
14
+ #
15
+ # Observe → receive → forward → otherwise.
16
+ class Inward < Data.define(:observes, :receives, :forwards, :otherwises, :routes)
17
+ include Dispatch
18
+
19
+ def initialize(observes:, receives:, forwards:, otherwises:, routes:)
20
+ super
21
+ validate_routes!
22
+ end
23
+
24
+ def call(message, model)
25
+ transition = run_all(observes, message, model)
26
+ config = Configuration.new(message:, model: transition.model)
27
+ apply_first_matching(receives, config, transition) ||
28
+ apply_first_matching(forwards, config, transition) ||
29
+ apply_first_matching(otherwises, config, transition) ||
30
+ transition
31
+ end
32
+
33
+ private def validate_routes!
34
+ forwards.each { |fwd| Array(fwd.targets).each { |t| routes.for(t) } unless fwd.targets == ALL_ROUTES }
35
+ otherwises.each { |ow| routes.for(ow.target) }
36
+ end
37
+ end
38
+ end
39
+ private_constant :Flow
40
+ end
41
+ end