rooibos 0.5.0 → 0.6.1

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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +57 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_rails_developers.md +17 -0
  53. data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
  54. data/doc/getting_started/for_react_developers.md +17 -0
  55. data/doc/getting_started/index.md +52 -0
  56. data/doc/getting_started/install.md +20 -0
  57. data/doc/getting_started/quickstart.md +9 -45
  58. data/doc/getting_started/ruby_primer.md +19 -0
  59. data/doc/getting_started/why_rooibos.md +20 -0
  60. data/doc/index.md +79 -11
  61. data/doc/scaling_up/async_patterns.md +20 -0
  62. data/doc/scaling_up/command_composition.md +20 -0
  63. data/doc/scaling_up/custom_commands.md +21 -0
  64. data/doc/scaling_up/fractal_architecture.md +20 -0
  65. data/doc/scaling_up/index.md +30 -0
  66. data/doc/scaling_up/message_routing.md +20 -0
  67. data/doc/scaling_up/ractor_safety.md +20 -0
  68. data/doc/scaling_up/testing.md +21 -0
  69. data/doc/troubleshooting/common_errors.md +20 -0
  70. data/doc/troubleshooting/debugging.md +21 -0
  71. data/doc/troubleshooting/index.md +23 -0
  72. data/doc/troubleshooting/performance.md +20 -0
  73. data/doc/tutorial/01_project_setup.md +44 -0
  74. data/doc/tutorial/02_hello_world.md +45 -0
  75. data/doc/tutorial/03_static_file_list.md +44 -0
  76. data/doc/tutorial/04_arrow_navigation.md +47 -0
  77. data/doc/tutorial/05_real_files.md +45 -0
  78. data/doc/tutorial/06_safe_refactoring.md +21 -0
  79. data/doc/tutorial/07_red_first_tdd.md +26 -0
  80. data/doc/tutorial/08_file_metadata.md +42 -0
  81. data/doc/tutorial/09_text_preview.md +44 -0
  82. data/doc/tutorial/10_directory_tree.md +42 -0
  83. data/doc/tutorial/11_pane_focus.md +40 -0
  84. data/doc/tutorial/12_sorting.md +41 -0
  85. data/doc/tutorial/13_filtering.md +43 -0
  86. data/doc/tutorial/14_toggle_hidden.md +41 -0
  87. data/doc/tutorial/15_text_input_widget.md +43 -0
  88. data/doc/tutorial/16_rename_files.md +42 -0
  89. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  90. data/doc/tutorial/18_progress_indicators.md +43 -0
  91. data/doc/tutorial/19_atomic_operations.md +42 -0
  92. data/doc/tutorial/20_external_editor.md +42 -0
  93. data/doc/tutorial/21_modal_overlays.md +41 -0
  94. data/doc/tutorial/22_error_handling.md +43 -0
  95. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  96. data/doc/tutorial/24_mouse_events.md +43 -0
  97. data/doc/tutorial/25_resize_events.md +43 -0
  98. data/doc/tutorial/26_loading_states.md +42 -0
  99. data/doc/tutorial/27_performance.md +43 -0
  100. data/doc/tutorial/28_color_schemes.md +47 -0
  101. data/doc/tutorial/29_configuration.md +124 -0
  102. data/doc/tutorial/30_going_further.md +17 -0
  103. data/doc/tutorial/index.md +17 -0
  104. data/examples/app_file_browser/app.rb +40 -0
  105. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  106. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  107. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  108. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  112. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  113. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  114. data/examples/verify_website_first_app/app.rb +85 -0
  115. data/examples/verify_website_hello_mvu/app.rb +31 -0
  116. data/examples/widget_command_system/app.rb +15 -13
  117. data/exe/rooibos +10 -0
  118. data/generate_tutorial_stubs.rb +126 -0
  119. data/lib/rooibos/cli/commands/new.rb +373 -0
  120. data/lib/rooibos/cli/commands/run.rb +98 -0
  121. data/lib/rooibos/cli.rb +78 -0
  122. data/lib/rooibos/command/all.rb +76 -23
  123. data/lib/rooibos/command/batch.rb +61 -34
  124. data/lib/rooibos/command/custom.rb +84 -1
  125. data/lib/rooibos/command/http.rb +121 -55
  126. data/lib/rooibos/command/lifecycle.rb +5 -5
  127. data/lib/rooibos/command/open.rb +93 -0
  128. data/lib/rooibos/command/outlet.rb +105 -3
  129. data/lib/rooibos/command/wait.rb +9 -6
  130. data/lib/rooibos/command.rb +114 -89
  131. data/lib/rooibos/message/batch.rb +39 -0
  132. data/lib/rooibos/message/canceled.rb +51 -0
  133. data/lib/rooibos/message/error.rb +48 -0
  134. data/lib/rooibos/message/open.rb +30 -0
  135. data/lib/rooibos/message.rb +84 -4
  136. data/lib/rooibos/router.rb +11 -14
  137. data/lib/rooibos/runtime.rb +40 -43
  138. data/lib/rooibos/shortcuts.rb +47 -0
  139. data/lib/rooibos/test_helper.rb +71 -6
  140. data/lib/rooibos/version.rb +1 -1
  141. data/lib/rooibos/welcome.rb +237 -0
  142. data/lib/rooibos.rb +4 -3
  143. data/mise.toml +1 -1
  144. data/rbs_collection.lock.yaml +2 -2
  145. data/sig/concurrent.rbs +4 -0
  146. data/sig/gem.rbs +20 -0
  147. data/sig/rooibos/cli.rbs +42 -0
  148. data/sig/rooibos/command.rbs +59 -7
  149. data/sig/rooibos/message.rbs +66 -2
  150. data/sig/rooibos/shortcuts.rbs +14 -0
  151. data/sig/rooibos/test_helper.rbs +6 -2
  152. data/sig/rooibos/welcome.rbs +75 -0
  153. data/tasks/install.rake +29 -0
  154. data/tasks/resources/build.yml.erb +2 -0
  155. metadata +274 -38
  156. data/doc/concepts/application_architecture.md +0 -197
  157. data/doc/concepts/application_testing.md +0 -49
  158. data/doc/concepts/async_work.md +0 -164
  159. data/doc/concepts/commands.md +0 -530
  160. data/doc/concepts/message_processing.md +0 -51
  161. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  162. data/doc/contributors/WIP/implementation_plan.md +0 -409
  163. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  164. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  165. data/doc/contributors/WIP/task.md +0 -36
  166. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  167. data/doc/contributors/kit-no-outlet.md +0 -238
  168. data/doc/contributors/priorities.md +0 -38
  169. data/doc/images/.gitkeep +0 -0
  170. data/exe/.gitkeep +0 -0
  171. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -7,39 +7,92 @@
7
7
 
8
8
  module Rooibos
9
9
  module Command
10
- # An aggregating parallel command.
11
- All = Data.define(:envelope, :commands, :nested) do
10
+ # Aggregates parallel commands and returns all results together.
11
+ #
12
+ # Dashboards load user profiles, settings, and stats before rendering.
13
+ # Fetching sequentially is slow. Fire-and-forget batches lose correlation
14
+ # between commands and their results.
15
+ #
16
+ # This command runs children in parallel and collects their results into
17
+ # a single <tt>Message::All</tt> response. Pattern-match on the envelope
18
+ # to correlate results. Each result appears in the same order as commands.
19
+ #
20
+ # Use it for coordinated fetches where you need all results before proceeding.
21
+ #
22
+ # Prefer the <tt>Command.all</tt> factory method for convenience.
23
+ #
24
+ # === Example
25
+ #
26
+ # # Using the factory method (recommended)
27
+ # Command.all(:dashboard,
28
+ # Command.http(:get, "/users", :_),
29
+ # Command.http(:get, "/stats", :_),
30
+ # )
31
+ #
32
+ # # Using the class directly
33
+ # All.new(:dashboard,
34
+ # Command.http(:get, "/users", :_),
35
+ # Command.http(:get, "/stats", :_),
36
+ # )
37
+ #
38
+ # # Pattern-match on the aggregated result
39
+ # def update(message, model)
40
+ # case message
41
+ # in { type: :all, envelope: :dashboard, results: [users, stats] }
42
+ # model.with(users:, stats:, loading: false)
43
+ # end
44
+ # end
45
+ class All < Data.define(:envelope, :commands, :nested)
12
46
  include Custom
13
47
 
14
- def self.new(tag, *args)
15
- # DWIM: detect nested vs splatted based on call-site arity
16
- if args.size == 1 && args.first.is_a?(Array)
17
- commands = args.first
18
- nested = true
19
- else
20
- commands = args
21
- nested = false
22
- end
48
+ class << self
49
+ undef_method :new
50
+
51
+ # Creates an aggregating parallel command.
52
+ #
53
+ # [tag] Symbol to tag the result message.
54
+ # [args] Commands to run in parallel. Pass as multiple arguments
55
+ # or a single array.
56
+ #
57
+ # === Example
58
+ #
59
+ # All.new(:dashboard,
60
+ # Command.http(:get, "/users", :_),
61
+ # Command.http(:get, "/stats", :_),
62
+ # )
63
+ def new(tag, *args)
64
+ # DWIM: flatten single-array arg to support both call patterns
65
+ nested = args.size == 1 && args.first.is_a?(Array)
66
+ commands = [args].flatten(2)
23
67
 
24
- if RatatuiRuby::Debug.enabled?
25
- commands.each do |cmd|
26
- unless Ractor.shareable?(cmd)
27
- raise Rooibos::Error::Invariant,
28
- "Command is not Ractor-shareable: #{cmd.inspect}\n" \
29
- "Use Ractor.make_shareable or a Data.define command."
68
+ if RatatuiRuby::Debug.enabled?
69
+ commands.each do |cmd|
70
+ unless Ractor.shareable?(cmd)
71
+ raise Rooibos::Error::Invariant,
72
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
73
+ "Use Ractor.make_shareable or a Data.define command."
74
+ end
30
75
  end
31
76
  end
32
- end
33
77
 
34
- instance = allocate
35
- instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
36
- instance
78
+ instance = allocate
79
+ instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
80
+ instance
81
+ end
37
82
  end
38
83
 
84
+ # Executes all child commands in parallel and aggregates results.
85
+ #
86
+ # Sends <tt>Message::All</tt> when all children complete. Results appear
87
+ # in the same order as commands. If canceled, sends <tt>Message::Canceled</tt>.
88
+ #
89
+ # [out] Outlet for sending messages.
90
+ # [token] Cancellation token from the runtime.
39
91
  def call(out, token)
40
92
  # Early return for empty commands - prevents hang from zip_futures([])
41
93
  if commands.empty?
42
- response = Message::All.new(envelope:, results: [].freeze, nested:)
94
+ results = [] #: Array[Object]
95
+ response = Message::All.new(envelope:, results: results.freeze, nested:)
43
96
  out.put(Ractor.make_shareable(response))
44
97
  return
45
98
  end
@@ -58,7 +111,7 @@ module Rooibos
58
111
  all_done = Concurrent::Promises.zip_futures(*futures)
59
112
  Concurrent::Promises.any_event(all_done, token.origin).wait
60
113
 
61
- return out.put(Command.cancel(self)) if token.canceled?
114
+ return out.put(Message::Canceled.new(command: self)) if token.canceled?
62
115
 
63
116
  shareable_results = Ractor.make_shareable(all_done.value!)
64
117
  response = Message::All.new(envelope:, results: shareable_results, nested:)
@@ -15,61 +15,88 @@ module Rooibos
15
15
  #
16
16
  # This command runs children in parallel. Each child sends its own messages
17
17
  # independently. The batch completes when all children finish or when
18
- # cancellation fires. On cancellation, emits <tt>Command.cancel(self)</tt>.
18
+ # cancellation fires. On cancellation, emits <tt>Message::Canceled</tt>.
19
19
  #
20
20
  # Use it for parallel fetches, concurrent refreshes, or any work that
21
21
  # does not need coordinated results.
22
22
  #
23
+ # Prefer the <tt>Command.batch</tt> factory method for convenience.
24
+ #
23
25
  # === Example
24
26
  #
27
+ # # Using the factory method (recommended)
28
+ # Command.batch(
29
+ # Command.http(:get, "/users", :users),
30
+ # Command.http(:get, "/stats", :stats),
31
+ # )
32
+ #
33
+ # # Using the class directly
34
+ # Batch.new(
35
+ # Command.http(:get, "/users", :users),
36
+ # Command.http(:get, "/stats", :stats),
37
+ # )
38
+ #
39
+ # # Handle each response independently
25
40
  # def update(msg, model)
26
41
  # case msg
27
- # in :refresh_all
28
- # batch = Command.batch(
29
- # Command.http(:get, "/users", :users),
30
- # Command.http(:get, "/stats", :stats),
31
- # )
32
- # [model.with(loading: true), batch]
33
- # in :users | :stats
34
- # [model.with(msg => data), nil]
42
+ # in { type: :http, envelope: :users, body: }
43
+ # model.with(users: JSON.parse(body))
44
+ # in { type: :http, envelope: :stats, body: }
45
+ # model.with(stats: JSON.parse(body))
35
46
  # end
36
47
  # end
37
48
  class Batch < Data.define(:commands) do
38
49
  include Custom
39
50
 
40
- # Initialize
41
- def self.new(*args)
42
- # DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
43
- commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
51
+ class << self
52
+ undef_method :new
44
53
 
45
- if RatatuiRuby::Debug.enabled?
46
- commands.each do |cmd|
47
- unless Ractor.shareable?(cmd)
48
- raise Rooibos::Error::Invariant,
49
- "Command is not Ractor-shareable: #{cmd.inspect}\n" \
50
- "Use Ractor.make_shareable or a Data.define command."
54
+ # Creates a parallel batch command.
55
+ #
56
+ # [args] Commands to run in parallel. Pass as multiple arguments
57
+ # or a single array.
58
+ #
59
+ # === Example
60
+ #
61
+ # Batch.new(cmd1, cmd2, cmd3)
62
+ # Batch.new([cmd1, cmd2, cmd3])
63
+ def new(*args)
64
+ # DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
65
+ commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
66
+
67
+ if RatatuiRuby::Debug.enabled?
68
+ commands.each do |cmd|
69
+ unless Ractor.shareable?(cmd)
70
+ raise Rooibos::Error::Invariant,
71
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
72
+ "Use Ractor.make_shareable or a Data.define command."
73
+ end
51
74
  end
52
75
  end
53
- end
54
76
 
55
- instance = allocate
56
- instance.__send__(:initialize, commands: commands.freeze)
57
- instance
77
+ instance = allocate
78
+ instance.__send__(:initialize, commands: commands.freeze)
79
+ instance
80
+ end
58
81
  end
59
82
 
60
- # Call it
83
+ # Executes all child commands in parallel.
84
+ #
85
+ # Each child sends its results independently via the runtime.
86
+ # When all complete, sends <tt>Message::Batch</tt>. If canceled,
87
+ # sends <tt>Message::Canceled</tt> instead.
88
+ #
89
+ # [out] Outlet for sending messages.
90
+ # [token] Cancellation token from the runtime.
61
91
  def call(out, token)
62
- futures = commands.map do |command|
63
- Concurrent::Promises.future { command.call(out, token) }
64
- end
65
-
66
- all_done = Concurrent::Promises.zip_futures(*futures)
67
- Concurrent::Promises.any_event(all_done, token.origin).wait
92
+ handles = commands.map { |cmd| out.standing(cmd, token) }
93
+ out.wait(*handles, token:)
68
94
 
69
- # Re-raise any child exception for runtime to wrap in Command::Error
70
- futures.each { |f| raise f.reason if f.rejected? }
71
-
72
- out.put(Command.cancel(self)) if token.canceled?
95
+ if token.canceled?
96
+ out.put(Message::Canceled.new(command: self))
97
+ else
98
+ out.put(Message::Batch.new(command: self))
99
+ end
73
100
  end
74
101
  end
75
102
  end
@@ -71,7 +71,7 @@ module Rooibos
71
71
  # it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
72
72
  # If your command does not exit within this window, it is force-killed.
73
73
  #
74
- # *This is NOT a lifetime limit.* Your command runs indefinitely until cancelled.
74
+ # *This is NOT a lifetime limit.* Your command runs indefinitely until canceled.
75
75
  # A WebSocket open for 15 minutes is fine. This timeout only applies to the
76
76
  # cleanup phase after cancellation is requested.
77
77
  #
@@ -99,6 +99,89 @@ module Rooibos
99
99
  def rooibos_cancellation_grace_period
100
100
  0.1
101
101
  end
102
+
103
+ # Infrastructure methods to exclude from introspection.
104
+ # Computed once from bare prototypes.
105
+ INFRASTRUCTURE_METHODS = begin
106
+ bare_data = Data.define(:_)
107
+ bare_struct = Struct.new(:_)
108
+
109
+ methods = Object.public_instance_methods +
110
+ bare_data.public_instance_methods +
111
+ bare_struct.public_instance_methods
112
+
113
+ # PP methods can error when called on objects without pretty_print override
114
+ methods += %i[
115
+ pretty_print
116
+ pretty_print_cycle
117
+ pretty_print_instance_variables
118
+ pretty_print_inspect
119
+ ]
120
+
121
+ methods.uniq.freeze
122
+ end
123
+ private_constant :INFRASTRUCTURE_METHODS
124
+
125
+ # Deconstructs for hash-based pattern matching.
126
+ #
127
+ # Introspects public query methods (CQS: zero-arity, no side effects) and
128
+ # returns a hash suitable for +case+/+in+ matching. Excludes infrastructure
129
+ # methods from Object, Data, and Struct.
130
+ #
131
+ # Always includes +:type+ as a snake_case symbol of the class name.
132
+ # Anonymous classes default to +:custom+.
133
+ #
134
+ # Data.define members are automatically included since they generate
135
+ # public accessor methods.
136
+ #
137
+ # This is a naive but practical default. Override for:
138
+ # - Hot paths (introspects methods on every call)
139
+ # - Ghost methods via +method_missing+/+respond_to_missing?+
140
+ # - Methods with optional arguments (only zero-arity detected)
141
+ #
142
+ # @param keys [Array<Symbol>, nil] Limit output to specific keys for performance.
143
+ # Pass +nil+ to include all keys.
144
+ # @return [Hash{Symbol => Object}] Deconstructed hash with +:type+ discriminator.
145
+ #
146
+ # @example Pattern matching with Data.define command
147
+ # case msg
148
+ # in { type: :http_response, envelope: :users, status: 200 }
149
+ # # handle success
150
+ # end
151
+ def deconstruct_keys(keys)
152
+ class_name = self.class.name&.split("::")&.last
153
+ type_name = if class_name
154
+ class_name
155
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
156
+ .downcase
157
+ .to_sym
158
+ else
159
+ :custom
160
+ end
161
+
162
+ result = { type: type_name }
163
+
164
+ # Include Data.define/Struct members
165
+ if self.class.respond_to?(:members)
166
+ klass = self.class #: Class & _HasMembers
167
+ klass.members.each do |member|
168
+ next if keys && !keys.include?(member)
169
+ result[member] = public_send(member)
170
+ end
171
+ end
172
+
173
+ # Include public zero-arity query methods (excluding infrastructure)
174
+ # Use Kernel#public_method to avoid collision with Data.define :method member
175
+ get_method = Kernel.instance_method(:public_method)
176
+ (public_methods - INFRASTRUCTURE_METHODS).each do |method_name|
177
+ next if method_name.to_s.end_with?("=", "!")
178
+ next unless get_method.bind_call(self, method_name).arity.zero?
179
+ next if keys && !keys.include?(method_name)
180
+ result[method_name] = public_send(method_name)
181
+ end
182
+
183
+ result
184
+ end
102
185
  end
103
186
  end
104
187
  end
@@ -14,80 +14,135 @@ module Rooibos
14
14
  # New code should use Rooibos::Message::HttpResponse.
15
15
  HttpResponse = Message::HttpResponse
16
16
 
17
- # An HTTP request command.
18
- Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
17
+ # Performs HTTP requests and sends the response as a message.
18
+ #
19
+ # Applications fetch data from APIs. Users expect responsive interfaces
20
+ # while requests complete. Managing HTTP connections, timeouts, and
21
+ # threading manually is error-prone.
22
+ #
23
+ # This command executes HTTP requests off the main thread. The runtime
24
+ # dispatches it and routes the response back to your update function
25
+ # as a <tt>Message::HttpResponse</tt>.
26
+ #
27
+ # Use it to fetch API data, post forms, or interact with web services.
28
+ #
29
+ # Prefer the <tt>Command.http</tt> factory method for convenience.
30
+ # The constructor supports flexible DWIM (Do What I Mean) arity.
31
+ #
32
+ # === Example
33
+ #
34
+ # # Using the factory method (recommended)
35
+ # Command.http(:get, "/api/users", :users)
36
+ # Command.http(get: "/api/users", envelope: :users)
37
+ # Command.http(:post, "/api/users", '{"name":"Jo"}', :created)
38
+ #
39
+ # # Using the class directly
40
+ # Http.new(:get, "/api/users", :users)
41
+ #
42
+ # # Pattern-match on the response
43
+ # def update(message, model)
44
+ # case message
45
+ # in { type: :http, envelope: :users, status: 200, body: }
46
+ # model.with(users: JSON.parse(body))
47
+ # in { type: :http, envelope: :users, error: }
48
+ # model.with(error:)
49
+ # end
50
+ # end
51
+ class Http < Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser)
19
52
  include Custom
20
53
 
21
- def self.new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
22
- get: nil, post: nil, put: nil, patch: nil, delete: nil
23
- )
24
- # Auto-splat single hash argument
25
- return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
54
+ class << self
55
+ undef_method :new
26
56
 
27
- # Auto-spread single array argument
28
- return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
57
+ # Creates an HTTP request command.
58
+ #
59
+ # Supports flexible DWIM arity for convenience:
60
+ # <tt>Http.new("url")</tt>:: GET, URL as envelope
61
+ # <tt>Http.new("url", :tag)</tt>:: GET, custom envelope
62
+ # <tt>Http.new(:post, "url")</tt>:: POST, URL as envelope
63
+ # <tt>Http.new(:post, "url", :tag)</tt>:: POST, custom envelope
64
+ # <tt>Http.new(:post, "url", "body", :tag)</tt>:: POST with body
65
+ # <tt>Http.new(get: "url")</tt>:: keyword shortcut
66
+ #
67
+ # [method] HTTP method symbol: <tt>:get</tt>, <tt>:post</tt>,
68
+ # <tt>:put</tt>, <tt>:patch</tt>, or <tt>:delete</tt>.
69
+ # [url] Request URL (String).
70
+ # [envelope] Symbol to tag the response message.
71
+ # [headers] Optional hash of HTTP headers.
72
+ # [body] Optional request body (String).
73
+ # [timeout] Optional timeout in seconds (default 10).
74
+ # [parser] Optional callable to transform response body.
75
+ def new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
76
+ get: nil, post: nil, put: nil, patch: nil, delete: nil
77
+ )
78
+ # Auto-splat single hash argument
79
+ return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
29
80
 
30
- # DWIM: parse positional args and keyword method shortcuts
31
- method_keywords = { get:, post:, put:, patch:, delete: }.compact
32
- method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
81
+ # Auto-spread single array argument
82
+ return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
33
83
 
34
- # Ractor validation
35
- if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
36
- raise Rooibos::Error::Invariant,
37
- "URL is not Ractor-shareable: #{url.inspect}\n" \
38
- "Use a frozen string or Ractor.make_shareable."
39
- end
84
+ # DWIM: parse positional args and keyword method shortcuts
85
+ method_keywords = { get:, post:, put:, patch:, delete: }.compact
86
+ method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
40
87
 
41
- if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
42
- raise Rooibos::Error::Invariant,
43
- "Headers are not Ractor-shareable: #{headers.inspect}\n" \
44
- "Use Ractor.make_shareable or freeze the hash and its contents."
45
- end
88
+ # Ractor validation
89
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
90
+ raise Rooibos::Error::Invariant,
91
+ "URL is not Ractor-shareable: #{url.inspect}\n" \
92
+ "Use a frozen string or Ractor.make_shareable."
93
+ end
46
94
 
47
- if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
48
- raise Rooibos::Error::Invariant,
49
- "Body is not Ractor-shareable: #{body.inspect}\n" \
50
- "Use a frozen string or Ractor.make_shareable."
51
- end
95
+ if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
96
+ raise Rooibos::Error::Invariant,
97
+ "Headers are not Ractor-shareable: #{headers.inspect}\n" \
98
+ "Use Ractor.make_shareable or freeze the hash and its contents."
99
+ end
52
100
 
53
- if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
54
- raise Rooibos::Error::Invariant,
55
- "Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
56
- "Use a frozen string, symbol, or Ractor.make_shareable."
57
- end
101
+ if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
102
+ raise Rooibos::Error::Invariant,
103
+ "Body is not Ractor-shareable: #{body.inspect}\n" \
104
+ "Use a frozen string or Ractor.make_shareable."
105
+ end
58
106
 
59
- if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
60
- raise Rooibos::Error::Invariant,
61
- "Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
62
- "Use a number or Ractor.make_shareable."
63
- end
107
+ if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
108
+ raise Rooibos::Error::Invariant,
109
+ "Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
110
+ "Use a frozen string, symbol, or Ractor.make_shareable."
111
+ end
64
112
 
65
- # Parser validation
66
- if parser && !parser.respond_to?(:call)
67
- raise ArgumentError, "parser: must respond to :call"
68
- end
113
+ if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
114
+ raise Rooibos::Error::Invariant,
115
+ "Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
116
+ "Use a number or Ractor.make_shareable."
117
+ end
69
118
 
70
- if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
71
- raise Rooibos::Error::Invariant,
72
- "Parser is not Ractor-shareable: #{parser.inspect}\n" \
73
- "Use a frozen Method object or Ractor.make_shareable."
74
- end
119
+ # Parser validation
120
+ if parser && !parser.respond_to?(:call)
121
+ raise ArgumentError, "parser: must respond to :call"
122
+ end
75
123
 
76
- # Method validation
77
- unless %i[get post put patch delete].include?(method)
78
- raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
79
- end
124
+ if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
125
+ raise Rooibos::Error::Invariant,
126
+ "Parser is not Ractor-shareable: #{parser.inspect}\n" \
127
+ "Use a frozen Method object or Ractor.make_shareable."
128
+ end
80
129
 
81
- instance = allocate
82
- instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
83
- instance
130
+ # Method validation
131
+ unless %i[get post put patch delete].include?(method)
132
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
133
+ end
134
+
135
+ instance = allocate
136
+ instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
137
+ instance
138
+ end
84
139
  end
85
140
 
86
141
  # Net::HTTP is blocking; no cooperative cancellation possible.
87
142
  # Grace period = 0 means runtime can force-kill immediately.
88
143
  def rooibos_cancellation_grace_period = 0
89
144
 
90
- def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
145
+ def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords) # :nodoc:
91
146
  # Handle keyword method shortcuts: get: 'url'
92
147
  if method_keywords.any?
93
148
  method_key, url = method_keywords.first
@@ -130,6 +185,17 @@ module Rooibos
130
185
  end
131
186
  private_class_method :parse_dwim_args
132
187
 
188
+ # Executes the HTTP request and sends the response.
189
+ #
190
+ # Sends <tt>Message::HttpResponse</tt> with status, body, and headers.
191
+ # On network errors, sends the same message type with <tt>error</tt>
192
+ # populated instead.
193
+ #
194
+ # Note: Ruby's <tt>Net::HTTP</tt> blocks until completion. Cancellation
195
+ # cannot interrupt a request in progress. The grace period is 0.
196
+ #
197
+ # [out] Outlet for sending messages.
198
+ # [token] Cancellation token from the runtime.
133
199
  def call(out, token)
134
200
  return if token.canceled?
135
201
 
@@ -19,8 +19,7 @@ module Rooibos
19
19
  #
20
20
  # The framework creates one instance at startup. All outlets share it.
21
21
  class Lifecycle
22
- # :nodoc: Internal representation of a tracked async command.
23
- Entry = Data.define(:future, :origin)
22
+ Entry = Data.define(:future, :origin) # :nodoc: Internal representation of a tracked async command.
24
23
 
25
24
  # Creates a lifecycle manager.
26
25
  #
@@ -40,7 +39,7 @@ module Rooibos
40
39
  # [token] Parent's cancellation token.
41
40
  # [timeout] Max wait seconds for the result.
42
41
  #
43
- # Returns the child's message, or <tt>nil</tt> if cancelled or timed out.
42
+ # Returns the child's message, or <tt>nil</tt> if canceled or timed out.
44
43
  # Raises if the child raised.
45
44
  def run_sync(command, token, timeout:)
46
45
  return nil if token.canceled?
@@ -76,7 +75,7 @@ module Rooibos
76
75
  #
77
76
  # Spawns a future that executes the command. Tracks the command in the
78
77
  # active map for cancellation support. Errors are pushed to the channel
79
- # as <tt>Command::Error</tt> messages.
78
+ # as <tt>Message::Error</tt> messages.
80
79
  #
81
80
  # [command] Callable with <tt>call(out, token)</tt>.
82
81
  # [channel] Channel to push results and errors to.
@@ -89,7 +88,7 @@ module Rooibos
89
88
  future = Concurrent::Promises.future do
90
89
  command.call(outlet, cancellation)
91
90
  rescue => e
92
- channel.push Command::Error.new(command:, exception: e)
91
+ channel.push Message::Error.new(command:, exception: e)
93
92
  end
94
93
 
95
94
  entry = Entry.new(future:, origin:)
@@ -114,6 +113,7 @@ module Rooibos
114
113
  entry.future.wait(grace.finite? ? grace : nil)
115
114
 
116
115
  @active.delete(command)
116
+ entry # Return so caller can remove from pending_futures
117
117
  end
118
118
 
119
119
  # Cancels all active commands and waits for them to complete.