rage-rb 1.17.1 → 1.19.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.
@@ -9,7 +9,7 @@ class RageController::API
9
9
  # returns the name of the newly defined method;
10
10
  # rubocop:disable Layout/IndentationWidth, Layout/EndAlignment, Layout/HeredocIndentation
11
11
  def __register_action(action)
12
- raise Rage::Errors::RouterError, "The action '#{action}' could not be found for #{self}" unless method_defined?(action)
12
+ raise Rage::Errors::RouterError, "The action `#{action}` could not be found in the `#{self}` controller. This is likely due to route helpers pointing to non-existent actions in the controller. Please check your routes and ensure that all referenced actions exist." unless method_defined?(action)
13
13
 
14
14
  around_actions_total = 0
15
15
 
@@ -153,7 +153,13 @@ class RageController::API
153
153
  <<~RUBY
154
154
  context = {}
155
155
  append_info_to_payload(context)
156
- Thread.current[:rage_logger][:context] = context
156
+
157
+ log_context = Thread.current[:rage_logger][:context]
158
+ if log_context.empty?
159
+ Thread.current[:rage_logger][:context] = context
160
+ else
161
+ Thread.current[:rage_logger][:context] = log_context.merge(context)
162
+ end
157
163
  RUBY
158
164
  end}
159
165
  end
@@ -448,7 +454,7 @@ class RageController::API
448
454
  # Get the request object. See {Rage::Request}.
449
455
  # @return [Rage::Request]
450
456
  def request
451
- @request ||= Rage::Request.new(@__env)
457
+ @request ||= Rage::Request.new(@__env, controller: self)
452
458
  end
453
459
 
454
460
  # Get the response object. See {Rage::Response}.
@@ -635,6 +641,12 @@ class RageController::API
635
641
  !still_fresh
636
642
  end
637
643
 
644
+ # Get the name of the currently executed action.
645
+ # @return [String] the name of the currently executed action
646
+ def action_name
647
+ @__params[:action]
648
+ end
649
+
638
650
  # @private
639
651
  # for comatibility with `Rails.application.routes.recognize_path`
640
652
  def self.binary_params_for?(_)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Context for deferred tasks.
5
+ # The class encapsulates the context associated with a deferred task, and allows to store it without modifying the task instance.
6
+ #
7
+ class Rage::Deferred::Context
8
+ def self.build(task, args, kwargs, storage: nil)
9
+ logger = Thread.current[:rage_logger]
10
+
11
+ [
12
+ task,
13
+ args.empty? ? nil : args,
14
+ kwargs.empty? ? nil : kwargs,
15
+ nil,
16
+ logger&.dig(:tags),
17
+ logger&.dig(:context)
18
+ ]
19
+ end
20
+
21
+ def self.get_task(context)
22
+ context[0]
23
+ end
24
+
25
+ def self.get_args(context)
26
+ context[1]
27
+ end
28
+
29
+ def self.get_kwargs(context)
30
+ context[2]
31
+ end
32
+
33
+ def self.get_attempts(context)
34
+ context[3]
35
+ end
36
+
37
+ def self.inc_attempts(context)
38
+ context[3] = context[3].to_i + 1
39
+ end
40
+
41
+ def self.get_log_tags(context)
42
+ context[4]
43
+ end
44
+
45
+ def self.get_log_context(context)
46
+ context[5]
47
+ end
48
+ end
@@ -79,12 +79,16 @@ module Rage::Deferred
79
79
 
80
80
  class PushTimeout < StandardError
81
81
  end
82
+
83
+ # @private
84
+ class TaskFailed < StandardError
85
+ end
82
86
  end
83
87
 
84
88
  require_relative "task"
85
89
  require_relative "queue"
86
90
  require_relative "proxy"
87
- require_relative "metadata"
91
+ require_relative "context"
88
92
  require_relative "backends/disk"
89
93
  require_relative "backends/nil"
90
94
 
@@ -10,7 +10,7 @@ class Rage::Deferred::Queue
10
10
  end
11
11
 
12
12
  # Write the task to the storage and schedule it for execution.
13
- def enqueue(task_metadata, delay: nil, delay_until: nil, task_id: nil)
13
+ def enqueue(context, delay: nil, delay_until: nil, task_id: nil)
14
14
  apply_backpressure if @backpressure
15
15
 
16
16
  publish_in, publish_at = if delay
@@ -21,14 +21,14 @@ class Rage::Deferred::Queue
21
21
  [delay_until_i - current_time_i, delay_until_i] if delay_until_i > current_time_i
22
22
  end
23
23
 
24
- persisted_task_id = @backend.add(task_metadata, publish_at:, task_id:)
25
- schedule(persisted_task_id, task_metadata, publish_in:)
24
+ persisted_task_id = @backend.add(context, publish_at:, task_id:)
25
+ schedule(persisted_task_id, context, publish_in:)
26
26
  end
27
27
 
28
28
  # Schedule the task for execution.
29
- def schedule(task_id, task_metadata, publish_in: nil)
29
+ def schedule(task_id, context, publish_in: nil)
30
30
  publish_in_ms = publish_in.to_i * 1_000 if publish_in && publish_in > 0
31
- task = Rage::Deferred::Metadata.get_task(task_metadata)
31
+ task = Rage::Deferred::Context.get_task(context)
32
32
  @backlog_size += 1 unless publish_in_ms
33
33
 
34
34
  Iodine.run_after(publish_in_ms) do
@@ -38,14 +38,14 @@ class Rage::Deferred::Queue
38
38
  Fiber.schedule do
39
39
  Iodine.task_inc!
40
40
 
41
- is_completed = task.new.__perform(task_metadata)
41
+ is_completed = task.new.__perform(context)
42
42
 
43
43
  if is_completed
44
44
  @backend.remove(task_id)
45
45
  else
46
- attempts = Rage::Deferred::Metadata.inc_attempts(task_metadata)
46
+ attempts = Rage::Deferred::Context.inc_attempts(context)
47
47
  if task.__should_retry?(attempts)
48
- enqueue(task_metadata, delay: task.__next_retry_in(attempts), task_id:)
48
+ enqueue(context, delay: task.__next_retry_in(attempts), task_id:)
49
49
  else
50
50
  @backend.remove(task_id)
51
51
  end
@@ -41,32 +41,36 @@ module Rage::Deferred::Task
41
41
  end
42
42
 
43
43
  # @private
44
- def __with_optional_log_tag(tag)
45
- if tag
46
- Rage.logger.tagged(tag) { yield }
47
- else
48
- yield
44
+ def __perform(context)
45
+ args = Rage::Deferred::Context.get_args(context)
46
+ kwargs = Rage::Deferred::Context.get_kwargs(context)
47
+ attempts = Rage::Deferred::Context.get_attempts(context)
48
+
49
+ restore_log_info(context)
50
+
51
+ task_log_context = { task: self.class.name }
52
+ task_log_context[:attempt] = attempts + 1 if attempts
53
+
54
+ Rage.logger.with_context(task_log_context) do
55
+ perform(*args, **kwargs)
56
+ true
57
+ rescue Rage::Deferred::TaskFailed
58
+ false
59
+ rescue Exception => e
60
+ Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
61
+ false
49
62
  end
50
63
  end
51
64
 
52
- # @private
53
- def __perform(metadata)
54
- args = Rage::Deferred::Metadata.get_args(metadata)
55
- kwargs = Rage::Deferred::Metadata.get_kwargs(metadata)
56
- attempts = Rage::Deferred::Metadata.get_attempts(metadata)
57
- request_id = Rage::Deferred::Metadata.get_request_id(metadata)
58
-
59
- context = { task: self.class.name }
60
- context[:attempt] = attempts + 1 if attempts
65
+ private def restore_log_info(context)
66
+ log_tags = Rage::Deferred::Context.get_log_tags(context)
67
+ log_context = Rage::Deferred::Context.get_log_context(context)
61
68
 
62
- Rage.logger.with_context(context) do
63
- __with_optional_log_tag(request_id) do
64
- perform(*args, **kwargs)
65
- true
66
- rescue Exception => e
67
- Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
68
- false
69
- end
69
+ if log_tags.is_a?(Array)
70
+ Thread.current[:rage_logger] = { tags: log_tags, context: log_context }
71
+ elsif log_tags
72
+ # support the previous format where only `request_id` was passed
73
+ Thread.current[:rage_logger] = { tags: [log_tags], context: {} }
70
74
  end
71
75
  end
72
76
 
@@ -77,10 +81,12 @@ module Rage::Deferred::Task
77
81
  module ClassMethods
78
82
  def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
79
83
  Rage::Deferred.__queue.enqueue(
80
- Rage::Deferred::Metadata.build(self, args, kwargs),
84
+ Rage::Deferred::Context.build(self, args, kwargs),
81
85
  delay:,
82
86
  delay_until:
83
87
  )
88
+
89
+ nil
84
90
  end
85
91
 
86
92
  # @private
data/lib/rage/env.rb CHANGED
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ ##
4
+ # {Rage::Env Rage::Env} represents the current environment of the Rage application.
5
+ #
6
+ # The class automatically detects the application's current environment by checking the `RAGE_ENV`, `RAILS_ENV`, and `RACK_ENV` environment variables.
7
+ # It provides predicate methods to check which environment is active.
8
+ #
9
+ # @example
10
+ # # Start the application with `rage s -e preprod`
11
+ # Rage.env.development? # => false
12
+ # Rage.env.preprod? # => true
13
+ #
3
14
  class Rage::Env
4
15
  STANDARD_ENVS = %w(development test staging production)
5
16
 
@@ -28,6 +39,10 @@ class Rage::Env
28
39
  @env.to_sym
29
40
  end
30
41
 
42
+ def to_str
43
+ @env
44
+ end
45
+
31
46
  def to_s
32
47
  @env
33
48
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # `Rage::Events` provides a lightweight event-driven system for publishing and subscribing to events.
5
+ # Define events as data structures, register subscriber classes, and publish events to notify all relevant subscribers.
6
+ # Subscribers can process events and optionally receive additional context with each event.
7
+ #
8
+ # Define an event:
9
+ # ```ruby
10
+ # UserRegistered = Data.define(:user_id)
11
+ # ```
12
+ #
13
+ # Define a subscriber:
14
+ # ```ruby
15
+ # class SendWelcomeEmail
16
+ # include Rage::Events::Subscriber
17
+ # subscribe_to UserRegistered
18
+ #
19
+ # def call(event)
20
+ # puts "Sending welcome email to user #{event.user_id}"
21
+ # end
22
+ # end
23
+ # ```
24
+ #
25
+ # Publish an event:
26
+ # ```ruby
27
+ # Rage::Events.publish(UserRegistered.new(user_id: 1))
28
+ # ```
29
+ #
30
+ module Rage::Events
31
+ # Publish an event to all subscribers registered for the event's class or its ancestors.
32
+ # Optionally, additional context data can be provided and passed to each subscriber.
33
+ #
34
+ # @param event [Object] the event to publish
35
+ # @param context [Object] additional data to publish along with the event
36
+ # @example Publish an event
37
+ # Rage::Events.publish(MyEvent.new)
38
+ # @example Publish an event with context
39
+ # Rage::Events.publish(MyEvent.new, context: { published_at: Time.now })
40
+ def self.publish(event, context: nil)
41
+ handler = __event_handlers[event.class] || __build_event_handler(event.class)
42
+ handler.call(event, context)
43
+
44
+ nil
45
+ end
46
+
47
+ # @private
48
+ def self.__registered_subscribers
49
+ @__registered_subscribers ||= Hash.new { |hash, key| hash[key] = [] }
50
+ end
51
+
52
+ # @private
53
+ def self.__register_subscriber(event_class, handler_class)
54
+ __registered_subscribers[event_class] << handler_class
55
+ end
56
+
57
+ # @private
58
+ def self.__get_subscribers(event_class)
59
+ event_class.ancestors.take_while { |klass|
60
+ klass != Object && klass != Data
61
+ }.each_with_object([]) { |klass, memo|
62
+ memo.concat(__registered_subscribers[klass]).uniq! if __registered_subscribers.has_key?(klass)
63
+ }
64
+ end
65
+
66
+ # @private
67
+ def self.__event_handlers
68
+ @__event_handlers ||= {}
69
+ end
70
+
71
+ # @private
72
+ def self.__build_event_handler(event_class)
73
+ subscriber_calls = __get_subscribers(event_class).map do |subscriber_class|
74
+ subscriber_class.__register_rescue_handlers
75
+
76
+ arguments = "event"
77
+
78
+ context_type, _ = subscriber_class.instance_method(:call).parameters.find do |param_type, param_name|
79
+ param_name == :context || param_type == :keyrest
80
+ end
81
+
82
+ if context_type
83
+ if context_type == :keyreq
84
+ arguments += ", context: context || {}"
85
+ else
86
+ arguments += ", context:"
87
+ end
88
+ end
89
+
90
+ if subscriber_class.__is_deferred
91
+ "#{subscriber_class}.enqueue(#{arguments})"
92
+ else
93
+ "#{subscriber_class}.new.__call(#{arguments})"
94
+ end
95
+ end
96
+
97
+ if subscriber_calls.empty?
98
+ ->(_, _) {}
99
+ else
100
+ __event_handlers[event_class] = eval <<-RUBY
101
+ ->(event, context) { #{subscriber_calls.join("; ")} }
102
+ RUBY
103
+ end
104
+ end
105
+
106
+ # @private
107
+ def self.__reset_subscribers
108
+ __registered_subscribers.clear
109
+ __event_handlers.clear
110
+
111
+ Rage::Events.__eager_load_subscribers
112
+ end
113
+
114
+ # @private
115
+ def self.__eager_load_subscribers
116
+ subscribers = Dir["#{Rage.root}/app/**/*.rb"].select do |path|
117
+ File.foreach(path).any? do |line|
118
+ line.include?("include Rage::Events::Subscriber") || line.include?("subscribe_to")
119
+ end
120
+ end
121
+
122
+ subscribers.each do |path|
123
+ Rage.code_loader.load_file(path)
124
+ end
125
+
126
+ rescue => e
127
+ puts "ERROR: Failed to load an event subscriber: #{e.class} (#{e.message})."
128
+ puts e.backtrace.join("\n")
129
+ end
130
+ end
131
+
132
+ require_relative "subscriber"
133
+
134
+ if Rage.env.development?
135
+ if Rage.config.internal.initialized?
136
+ Rage::Events.__eager_load_subscribers
137
+ else
138
+ Rage.config.after_initialize { Rage::Events.__eager_load_subscribers }
139
+ end
140
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Include this module in a class to make it an event subscriber.
5
+ #
6
+ # Example:
7
+ #
8
+ # ```ruby
9
+ # # Define an event class
10
+ # MyEvent = Data.define
11
+ #
12
+ # # Define the subscriber class
13
+ # class MySubscriber
14
+ # include Rage::Events::Subscriber
15
+ # subscribe_to MyEvent
16
+ #
17
+ # def call(event)
18
+ # puts "Received event: #{event.inspect}"
19
+ # end
20
+ # end
21
+ #
22
+ # # Publish an event
23
+ # Rage::Events.publish(MyEvent.new)
24
+ # ```
25
+ #
26
+ # When an event matching the specified class is published, the `call` method will be invoked with the event instance.
27
+ #
28
+ # You can also subscribe to multiple event classes:
29
+ #
30
+ # ```ruby
31
+ # class MySubscriber
32
+ # include Rage::Events::Subscriber
33
+ # subscribe_to EventA, EventB
34
+ #
35
+ # def call(event)
36
+ # puts "Received event: #{event.inspect}"
37
+ # end
38
+ # end
39
+ # ```
40
+ #
41
+ # Subscribers are executed synchronously by default. You can make a subscriber asynchronous by passing the `deferred` option:
42
+ #
43
+ # ```ruby
44
+ # class MySubscriber
45
+ # include Rage::Events::Subscriber
46
+ # subscribe_to MyEvent, deferred: true
47
+ #
48
+ # def call(event)
49
+ # puts "Received event in background: #{event.inspect}"
50
+ # end
51
+ # end
52
+ # ```
53
+ #
54
+ # Such subscriber will be executed in the background using Rage's deferred task system.
55
+ #
56
+ # You can also define custom error handling for exceptions raised during event processing using `rescue_from`:
57
+ #
58
+ # ```ruby
59
+ # class MySubscriber
60
+ # include Rage::Events::Subscriber
61
+ # subscribe_to MyEvent
62
+ #
63
+ # rescue_from StandardError do |exception|
64
+ # puts "An error occurred: #{exception.message}"
65
+ # end
66
+ # end
67
+ # ```
68
+ #
69
+ # @see ClassMethods
70
+ #
71
+ module Rage::Events::Subscriber
72
+ def self.included(handler_class)
73
+ handler_class.extend ClassMethods
74
+ end
75
+
76
+ # @private
77
+ def call(_)
78
+ end
79
+
80
+ # @private
81
+ def __call(event, context: nil)
82
+ Rage.logger.with_context(self.class.__log_context) do
83
+ context.nil? ? call(event) : call(event, context: context.freeze)
84
+ rescue Exception => _e
85
+ e = self.class.__rescue_handlers ? __run_rescue_handlers(_e) : _e
86
+
87
+ if e
88
+ Rage.logger.error("Subscriber failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
89
+ raise Rage::Deferred::TaskFailed if self.class.__is_deferred
90
+ end
91
+ end
92
+ end
93
+
94
+ module ClassMethods
95
+ # @private
96
+ attr_accessor :__event_classes, :__is_deferred, :__log_context, :__rescue_handlers
97
+
98
+ # Subscribe the class to one or more events.
99
+ #
100
+ # @param event_classes [Class, Array<Class>] one or more event classes to subscribe to
101
+ # @param deferred [Boolean] whether to process events asynchronously
102
+ def subscribe_to(*event_classes, deferred: false)
103
+ @__event_classes = (@__event_classes || []) | event_classes
104
+ @__is_deferred = !!deferred
105
+ @__log_context = { subscriber: name }.freeze
106
+
107
+ @__event_classes.each do |event_class|
108
+ Rage::Events.__register_subscriber(event_class, self)
109
+ end
110
+
111
+ if @__is_deferred
112
+ include Rage::Deferred::Task
113
+ alias_method :perform, :__call
114
+ end
115
+ end
116
+
117
+ # Define exception handlers for the subscriber.
118
+ #
119
+ # @param klasses [Class, Array<Class>] one or more exception classes to handle
120
+ # @param with [Symbol, String] the method name to call when an exception is raised
121
+ # @yield [exception] optional block to handle the exception
122
+ # @note If you do not re-raise exceptions in deferred subscribers, the subscriber will be marked as successful and Rage will not attempt to retry it.
123
+ def rescue_from(*klasses, with: nil, &block)
124
+ unless with
125
+ if block_given?
126
+ with = Rage::Internal.define_dynamic_method(self, block)
127
+ else
128
+ raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
129
+ end
130
+ end
131
+
132
+ if @__rescue_handlers.nil?
133
+ @__rescue_handlers = []
134
+ elsif @__rescue_handlers.frozen?
135
+ @__rescue_handlers = @__rescue_handlers.dup
136
+ end
137
+
138
+ @__rescue_handlers.unshift([klasses, with])
139
+ end
140
+
141
+ # @private
142
+ def inherited(klass)
143
+ klass.__rescue_handlers = @__rescue_handlers.freeze
144
+ klass.subscribe_to(*@__event_classes, deferred: @__is_deferred) if @__event_classes
145
+ end
146
+
147
+ # @private
148
+ def __register_rescue_handlers
149
+ return if method_defined?(:__run_rescue_handlers, false) || @__rescue_handlers.nil?
150
+
151
+ matcher_calls = @__rescue_handlers.map do |klasses, handler|
152
+ handler_call = instance_method(handler).arity == 0 ? handler : "#{handler}(exception)"
153
+
154
+ <<~RUBY
155
+ when #{klasses.join(", ")}
156
+ #{handler_call}
157
+ nil
158
+ RUBY
159
+ end
160
+
161
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
162
+ def __run_rescue_handlers(exception)
163
+ case exception
164
+ #{matcher_calls.join("\n")}
165
+ else
166
+ exception
167
+ end
168
+ rescue Exception => e
169
+ e
170
+ end
171
+ RUBY
172
+ end
173
+ end
174
+ end
data/lib/rage/fiber.rb CHANGED
@@ -118,6 +118,14 @@ class Fiber
118
118
  "block:#{object_id}:#{@__block_channel_i}"
119
119
  end
120
120
 
121
+ # @private
122
+ def __await_channel(force = false)
123
+ @__fiber_channel_i ||= 0
124
+ @__fiber_channel_i += 1 if force
125
+
126
+ "await:#{object_id}:#{@__fiber_channel_i}"
127
+ end
128
+
121
129
  # @private
122
130
  attr_accessor :__awaited_fileno
123
131
 
@@ -148,6 +156,7 @@ class Fiber
148
156
  # @note This method should only be used when multiple fibers have to be processed in parallel. There's no need to use `Fiber.await` for single IO calls.
149
157
  def self.await(fibers)
150
158
  f, fibers = Fiber.current, Array(fibers)
159
+ await_channel = f.__await_channel(true)
151
160
 
152
161
  # check which fibers are alive (i.e. have yielded) and which have errored out
153
162
  i, err, num_wait_for = 0, nil, 0
@@ -169,7 +178,7 @@ class Fiber
169
178
  end
170
179
 
171
180
  # wait on async fibers; resume right away if one of the fibers errors out
172
- Iodine.subscribe("await:#{f.object_id}") do |_, err|
181
+ Iodine.subscribe(await_channel) do |_, err|
173
182
  if err == AWAIT_ERROR_MESSAGE
174
183
  f.resume
175
184
  else
@@ -179,7 +188,7 @@ class Fiber
179
188
  end
180
189
 
181
190
  Fiber.defer(-1)
182
- Iodine.defer { Iodine.unsubscribe("await:#{f.object_id}") }
191
+ Iodine.defer { Iodine.unsubscribe(await_channel) }
183
192
 
184
193
  # if num_wait_for is not 0 means we exited prematurely because of an error
185
194
  if num_wait_for > 0
@@ -130,10 +130,10 @@ class Rage::FiberScheduler
130
130
  Thread.current[:rage_logger] = logger
131
131
  Fiber.current.__set_result(block.call)
132
132
  # send a message for `Fiber.await` to work
133
- Iodine.publish("await:#{parent.object_id}", "", Iodine::PubSub::PROCESS) if parent.alive?
133
+ Iodine.publish(parent.__await_channel, "", Iodine::PubSub::PROCESS) if parent.alive?
134
134
  rescue Exception => e
135
135
  Fiber.current.__set_err(e)
136
- Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
136
+ Iodine.publish(parent.__await_channel, Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
137
137
  end
138
138
  end
139
139
 
data/lib/rage/hooks.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # @private
3
4
  module Hooks
4
5
  def hooks
5
6
  @hooks ||= Hash.new { |h, k| h[k] = [] }
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @private
4
+ class Rage::Internal
5
+ class << self
6
+ # Define a method based on a block.
7
+ # @param klass [Class] the class to define the method in
8
+ # @param block [Proc] the implementation of the new method
9
+ # @return [Symbol] the name of the newly defined method
10
+ def define_dynamic_method(klass, block)
11
+ name = dynamic_name_seed.next.join
12
+ klass.define_method("__rage_dynamic_#{name}", block)
13
+ end
14
+
15
+ # Define a method that will call a specified method if a condition is `true` or yield if `false`.
16
+ # @param klass [Class] the class to define the method in
17
+ # @param method_name [Symbol] the method to call if the condition is `true`
18
+ # @return [Symbol] the name of the newly defined method
19
+ def define_maybe_yield(klass, method_name)
20
+ name = dynamic_name_seed.next.join
21
+
22
+ klass.class_eval <<~RUBY, __FILE__, __LINE__ + 1
23
+ def __rage_dynamic_#{name}(condition)
24
+ if condition
25
+ #{method_name} { yield }
26
+ else
27
+ yield
28
+ end
29
+ end
30
+ RUBY
31
+ end
32
+
33
+ # Build a string representation of keyword arguments based on the parameters expected by the method.
34
+ # @param method [Method, Proc] the method to build arguments for
35
+ # @param arguments [Hash] the arguments to include in the string representation
36
+ # @return [String] the string representation of the method arguments
37
+ def build_arguments(method, arguments)
38
+ expected_parameters = method.parameters
39
+
40
+ arguments.filter_map { |arg_name, arg_value|
41
+ if expected_parameters.any? { |param_type, param_name| param_name == arg_name || param_type == :keyrest }
42
+ "#{arg_name}: #{arg_value}"
43
+ end
44
+ }.join(", ")
45
+ end
46
+
47
+ private
48
+
49
+ def dynamic_name_seed
50
+ @dynamic_name_seed ||= ("a".."j").to_a.permutation
51
+ end
52
+ end
53
+ end