state_machines 0.10.0 → 0.30.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +177 -2
  3. data/lib/state_machines/branch.rb +16 -15
  4. data/lib/state_machines/callback.rb +11 -12
  5. data/lib/state_machines/core.rb +1 -3
  6. data/lib/state_machines/error.rb +5 -4
  7. data/lib/state_machines/eval_helpers.rb +83 -45
  8. data/lib/state_machines/event.rb +37 -27
  9. data/lib/state_machines/event_collection.rb +4 -5
  10. data/lib/state_machines/extensions.rb +5 -5
  11. data/lib/state_machines/helper_module.rb +1 -1
  12. data/lib/state_machines/integrations/base.rb +1 -1
  13. data/lib/state_machines/integrations.rb +11 -14
  14. data/lib/state_machines/machine/action_hooks.rb +53 -0
  15. data/lib/state_machines/machine/callbacks.rb +59 -0
  16. data/lib/state_machines/machine/class_methods.rb +25 -11
  17. data/lib/state_machines/machine/configuration.rb +124 -0
  18. data/lib/state_machines/machine/event_methods.rb +59 -0
  19. data/lib/state_machines/machine/helper_generators.rb +125 -0
  20. data/lib/state_machines/machine/integration.rb +70 -0
  21. data/lib/state_machines/machine/parsing.rb +77 -0
  22. data/lib/state_machines/machine/rendering.rb +17 -0
  23. data/lib/state_machines/machine/scoping.rb +44 -0
  24. data/lib/state_machines/machine/state_methods.rb +101 -0
  25. data/lib/state_machines/machine/utilities.rb +85 -0
  26. data/lib/state_machines/machine/validation.rb +39 -0
  27. data/lib/state_machines/machine.rb +75 -618
  28. data/lib/state_machines/machine_collection.rb +21 -15
  29. data/lib/state_machines/macro_methods.rb +2 -2
  30. data/lib/state_machines/matcher.rb +6 -6
  31. data/lib/state_machines/matcher_helpers.rb +1 -1
  32. data/lib/state_machines/node_collection.rb +21 -18
  33. data/lib/state_machines/options_validator.rb +72 -0
  34. data/lib/state_machines/path.rb +5 -5
  35. data/lib/state_machines/path_collection.rb +5 -4
  36. data/lib/state_machines/state.rb +29 -11
  37. data/lib/state_machines/state_collection.rb +3 -3
  38. data/lib/state_machines/state_context.rb +9 -8
  39. data/lib/state_machines/stdio_renderer.rb +16 -16
  40. data/lib/state_machines/syntax_validator.rb +57 -0
  41. data/lib/state_machines/test_helper.rb +568 -0
  42. data/lib/state_machines/transition.rb +43 -41
  43. data/lib/state_machines/transition_collection.rb +25 -26
  44. data/lib/state_machines/version.rb +1 -1
  45. metadata +25 -10
  46. data/lib/state_machines/assertions.rb +0 -42
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39c8073bdf33aff2e34bfc3403db686c7d6e4344f158e144cb881230b10d9597
4
- data.tar.gz: 150e14164e42addded79421e90456de2f60b4c451c743ee31a8eb2227d91896b
3
+ metadata.gz: 5c732f34387da18f4dc1c3d78cad1fbb111cc88d06d9fe3edcda912adc5e655a
4
+ data.tar.gz: ef0079a89a1b0e4ddea45dd4a79e11de12740d4eb0c2aa98ad95b5ca7ae3eab8
5
5
  SHA512:
6
- metadata.gz: 34af008f9378c058a199dd05d3204df5d995466f7db126589a8614d540bb636ce08c42f38796504753be8414431623e02b30c10d772fc2d524d4a2f9f4ba58e2
7
- data.tar.gz: a64f178f0f5ce8e13139b871c3cd351570df7c665f43338201225ff27d2482fe3cb3e1619f79e01544d4f9b3341bb9e589db7a659717e4975312684b6fd0ee82
6
+ metadata.gz: d71a5bd20b0de0b19e4684b4251954d0cf648e5454b57af96555372bbdee59a9ac855bb27d182e018f87ffa0525b5cd3daff5c3ce53c483f3dec4954d076a331
7
+ data.tar.gz: 179e0cb6c2a31d14c4078820d254ce35e26d9b5aaa2646e4766b842127411cf49e5ad697d0a764ba79a0036bce5dc288ef113b8bef57c3cac64de8816a23f49b
data/README.md CHANGED
@@ -1,5 +1,4 @@
1
1
  ![Build Status](https://github.com/state-machines/state_machines/actions/workflows/ruby.yml/badge.svg)
2
- [![Code Climate](https://codeclimate.com/github/state-machines/state_machines.svg)](https://codeclimate.com/github/state-machines/state_machines)
3
2
  # State Machines
4
3
 
5
4
  State Machines adds support for creating state machines for attributes on any Ruby class.
@@ -43,7 +42,7 @@ class Vehicle
43
42
 
44
43
  state_machine :state, initial: :parked do
45
44
  before_transition parked: any - :parked, do: :put_on_seatbelt
46
-
45
+
47
46
  after_transition on: :crash, do: :tow
48
47
  after_transition on: :repair, do: :fix
49
48
  after_transition any => :parked do |vehicle, transition|
@@ -255,6 +254,182 @@ vehicle.state_name # => :parked
255
254
  # vehicle.state = :parked
256
255
  ```
257
256
 
257
+ ## Testing
258
+
259
+ State Machines provides an optional `TestHelper` module with assertion methods to make testing state machines easier and more expressive.
260
+
261
+ **Note: TestHelper is not required by default** - you must explicitly require it in your test files.
262
+
263
+ ### Setup
264
+
265
+ First, require the test helper module, then include it in your test class:
266
+
267
+ ```ruby
268
+ # For Minitest
269
+ require 'state_machines/test_helper'
270
+
271
+ class VehicleTest < Minitest::Test
272
+ include StateMachines::TestHelper
273
+
274
+ def test_initial_state
275
+ vehicle = Vehicle.new
276
+ assert_sm_state vehicle, :parked
277
+ end
278
+ end
279
+
280
+ # For RSpec
281
+ require 'state_machines/test_helper'
282
+
283
+ RSpec.describe Vehicle do
284
+ include StateMachines::TestHelper
285
+
286
+ it "starts in parked state" do
287
+ vehicle = Vehicle.new
288
+ assert_sm_state vehicle, :parked
289
+ end
290
+ end
291
+ ```
292
+
293
+ ### Available Assertions
294
+
295
+ The TestHelper provides both basic assertions and comprehensive state machine-specific assertions with `sm_` prefixes:
296
+
297
+ #### Basic Assertions
298
+
299
+ ```ruby
300
+ vehicle = Vehicle.new
301
+
302
+ # New standardized API (all methods prefixed with assert_sm_)
303
+ assert_sm_state(vehicle, :parked) # Uses default :state machine
304
+ assert_sm_state(vehicle, :parked, machine_name: :status) # Specify machine explicitly
305
+ assert_sm_can_transition(vehicle, :ignite) # Test transition capability
306
+ assert_sm_cannot_transition(vehicle, :shift_up) # Test transition restriction
307
+ assert_sm_transition(vehicle, :ignite, :idling) # Test actual transition
308
+
309
+ # Multi-FSM examples
310
+ assert_sm_state(vehicle, :inactive, machine_name: :insurance_state) # Test insurance state
311
+ assert_sm_can_transition(vehicle, :buy_insurance, machine_name: :insurance_state)
312
+ ```
313
+
314
+ #### Extended State Machine Assertions
315
+
316
+ ```ruby
317
+ machine = Vehicle.state_machine(:state)
318
+ vehicle = Vehicle.new
319
+
320
+ # State configuration
321
+ assert_sm_states_list machine, [:parked, :idling, :stalled]
322
+ assert_sm_initial_state machine, :parked
323
+
324
+ # Event behavior
325
+ assert_sm_event_triggers vehicle, :ignite
326
+ refute_sm_event_triggers vehicle, :shift_up
327
+ assert_sm_event_raises_error vehicle, :invalid_event, StateMachines::InvalidTransition
328
+
329
+ # Persistence (with ActiveRecord integration)
330
+ assert_sm_state_persisted record, expected: :active
331
+ ```
332
+
333
+ #### Indirect Event Testing
334
+
335
+ Test that methods trigger state machine events indirectly:
336
+
337
+ ```ruby
338
+ # Minitest style
339
+ vehicle = Vehicle.new
340
+ vehicle.ignite # Put in idling state
341
+
342
+ # Test that a custom method triggers a specific event
343
+ assert_sm_triggers_event(vehicle, :crash) do
344
+ vehicle.redline # Custom method that calls crash! internally
345
+ end
346
+
347
+ # Test multiple events
348
+ assert_sm_triggers_event(vehicle, [:crash, :emergency]) do
349
+ vehicle.emergency_stop
350
+ end
351
+
352
+ # Test on specific state machine (multi-FSM support)
353
+ assert_sm_triggers_event(vehicle, :disable, machine_name: :alarm) do
354
+ vehicle.turn_off_alarm
355
+ end
356
+ ```
357
+
358
+ ```ruby
359
+ # RSpec style (coming soon with proper matcher support)
360
+ RSpec.describe Vehicle do
361
+ include StateMachines::TestHelper
362
+
363
+ it "triggers crash when redlining" do
364
+ vehicle = Vehicle.new
365
+ vehicle.ignite
366
+
367
+ expect_to_trigger_event(vehicle, :crash) do
368
+ vehicle.redline
369
+ end
370
+ end
371
+ end
372
+ ```
373
+
374
+ #### Callback Definition Testing (TDD Support)
375
+
376
+ Verify that callbacks are properly defined in your state machine:
377
+
378
+ ```ruby
379
+ # Test after_transition callbacks
380
+ assert_after_transition(Vehicle, on: :crash, do: :tow)
381
+ assert_after_transition(Vehicle, from: :stalled, to: :parked, do: :log_repair)
382
+
383
+ # Test before_transition callbacks
384
+ assert_before_transition(Vehicle, from: :parked, do: :put_on_seatbelt)
385
+ assert_before_transition(Vehicle, on: :ignite, if: :seatbelt_on?)
386
+
387
+ # Works with machine instances too
388
+ machine = Vehicle.state_machine(:state)
389
+ assert_after_transition(machine, on: :crash, do: :tow)
390
+ ```
391
+
392
+ #### Multiple State Machine Support
393
+
394
+ The TestHelper fully supports objects with multiple state machines:
395
+
396
+ ```ruby
397
+ # Example: StarfleetShip with 3 state machines
398
+ ship = StarfleetShip.new
399
+
400
+ # Test states on different machines
401
+ assert_sm_state(ship, :docked, machine_name: :status) # Main ship status
402
+ assert_sm_state(ship, :down, machine_name: :shields) # Shield system
403
+ assert_sm_state(ship, :standby, machine_name: :weapons) # Weapons system
404
+
405
+ # Test transitions on specific machines
406
+ assert_sm_transition(ship, :undock, :impulse, machine_name: :status)
407
+ assert_sm_transition(ship, :raise_shields, :up, machine_name: :shields)
408
+ assert_sm_transition(ship, :arm_weapons, :armed, machine_name: :weapons)
409
+
410
+ # Test event triggering across multiple machines
411
+ assert_sm_triggers_event(ship, :red_alert, machine_name: :status) do
412
+ ship.engage_combat_mode # Custom method affecting multiple systems
413
+ end
414
+
415
+ assert_sm_triggers_event(ship, :raise_shields, machine_name: :shields) do
416
+ ship.engage_combat_mode
417
+ end
418
+
419
+ # Test callback definitions on specific machines
420
+ shields_machine = StarfleetShip.state_machine(:shields)
421
+ assert_before_transition(shields_machine, from: :down, to: :up, do: :power_up_shields)
422
+
423
+ # Test persistence across multiple machines
424
+ assert_sm_state_persisted(ship, "impulse", :status)
425
+ assert_sm_state_persisted(ship, "up", :shields)
426
+ assert_sm_state_persisted(ship, "armed", :weapons)
427
+ ```
428
+
429
+ The test helper works with both Minitest and RSpec, automatically detecting your testing framework.
430
+
431
+ **Note:** All methods use consistent keyword arguments with `machine_name:` as the last parameter, making the API intuitive and Grep-friendly.
432
+
258
433
  ## Additional Topics
259
434
 
260
435
  ### Explicit vs. Implicit Event Transitions
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'options_validator'
4
+
3
5
  module StateMachines
4
6
  # Represents a set of requirements that must be met in order for a transition
5
7
  # or callback to occur. Branches verify that the event, from state, and to
6
8
  # state of the transition match, in addition to if/unless conditionals for
7
9
  # an object's state.
8
10
  class Branch
9
-
10
11
  include EvalHelpers
11
12
 
12
13
  # The condition that must be met on an object
@@ -29,7 +30,7 @@ module StateMachines
29
30
  attr_reader :known_states
30
31
 
31
32
  # Creates a new branch
32
- def initialize(options = {}) #:nodoc:
33
+ def initialize(options = {}) # :nodoc:
33
34
  # Build conditionals
34
35
  @if_condition = options.delete(:if)
35
36
  @unless_condition = options.delete(:unless)
@@ -37,9 +38,9 @@ module StateMachines
37
38
  # Build event requirement
38
39
  @event_requirement = build_matcher(options, :on, :except_on)
39
40
 
40
- if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on]).empty?
41
+ if (options.keys - %i[from to on except_from except_to except_on]).empty?
41
42
  # Explicit from/to requirements specified
42
- @state_requirements = [{from: build_matcher(options, :from, :except_from), to: build_matcher(options, :to, :except_to)}]
43
+ @state_requirements = [{ from: build_matcher(options, :from, :except_from), to: build_matcher(options, :to, :except_to) }]
43
44
  else
44
45
  # Separate out the event requirement
45
46
  options.delete(:on)
@@ -49,7 +50,7 @@ module StateMachines
49
50
  @state_requirements = options.collect do |from, to|
50
51
  from = WhitelistMatcher.new(from) unless from.is_a?(Matcher)
51
52
  to = WhitelistMatcher.new(to) unless to.is_a?(Matcher)
52
- {from: from, to: to}
53
+ { from: from, to: to }
53
54
  end
54
55
  end
55
56
 
@@ -57,7 +58,7 @@ module StateMachines
57
58
  # on the priority in which tracked states should be added.
58
59
  @known_states = []
59
60
  @state_requirements.each do |state_requirement|
60
- [:from, :to].each { |option| @known_states |= state_requirement[option].values }
61
+ %i[from to].each { |option| @known_states |= state_requirement[option].values }
61
62
  end
62
63
  end
63
64
 
@@ -114,24 +115,24 @@ module StateMachines
114
115
  # branch.match(object, :on => :ignite) # => {:to => ..., :from => ..., :on => ...}
115
116
  # branch.match(object, :on => :park) # => nil
116
117
  def match(object, query = {})
117
- query.assert_valid_keys(:from, :to, :on, :guard)
118
+ StateMachines::OptionsValidator.assert_valid_keys!(query, :from, :to, :on, :guard)
118
119
 
119
- if (match = match_query(query)) && matches_conditions?(object, query)
120
- match
121
- end
120
+ return unless (match = match_query(query)) && matches_conditions?(object, query)
121
+
122
+ match
122
123
  end
123
124
 
124
125
  def draw(graph, event, valid_states, io = $stdout)
125
126
  machine.renderer.draw_branch(self, graph, event, valid_states, io)
126
127
  end
127
128
 
128
- protected
129
+ protected
129
130
 
130
131
  # Builds a matcher strategy to use for the given options. If neither a
131
132
  # whitelist nor a blacklist option is specified, then an AllMatcher is
132
133
  # built.
133
134
  def build_matcher(options, whitelist_option, blacklist_option)
134
- options.assert_exclusive_keys(whitelist_option, blacklist_option)
135
+ StateMachines::OptionsValidator.assert_exclusive_keys!(options, whitelist_option, blacklist_option)
135
136
 
136
137
  if options.include?(whitelist_option)
137
138
  value = options[whitelist_option]
@@ -166,7 +167,7 @@ module StateMachines
166
167
  # matching requirement is found, then it is returned.
167
168
  def match_states(query)
168
169
  state_requirements.detect do |state_requirement|
169
- [:from, :to].all? { |option| matches_requirement?(query, option, state_requirement[option]) }
170
+ %i[from to].all? { |option| matches_requirement?(query, option, state_requirement[option]) }
170
171
  end
171
172
  end
172
173
 
@@ -180,8 +181,8 @@ module StateMachines
180
181
  # given object
181
182
  def matches_conditions?(object, query)
182
183
  query[:guard] == false ||
183
- Array(if_condition).all? { |condition| evaluate_method(object, condition) } &&
184
- !Array(unless_condition).any? { |condition| evaluate_method(object, condition) }
184
+ (Array(if_condition).all? { |condition| evaluate_method(object, condition) } &&
185
+ !Array(unless_condition).any? { |condition| evaluate_method(object, condition) })
185
186
  end
186
187
  end
187
188
  end
@@ -124,7 +124,7 @@ module StateMachines
124
124
  # callback can be found in their attribute definitions.
125
125
  def initialize(type, *args, &block)
126
126
  @type = type
127
- raise ArgumentError, 'Type must be :before, :after, :around, or :failure' unless [:before, :after, :around, :failure].include?(type)
127
+ raise ArgumentError, 'Type must be :before, :after, :around, or :failure' unless %i[before after around failure].include?(type)
128
128
 
129
129
  options = args.last.is_a?(Hash) ? args.pop : {}
130
130
  @methods = args
@@ -132,7 +132,7 @@ module StateMachines
132
132
  @methods << block if block_given?
133
133
  raise ArgumentError, 'Method(s) for callback must be specified' unless @methods.any?
134
134
 
135
- options = {bind_to_object: self.class.bind_to_object, terminator: self.class.terminator}.merge(options)
135
+ options = { bind_to_object: self.class.bind_to_object, terminator: self.class.terminator }.merge(options)
136
136
 
137
137
  # Proxy lambda blocks so that they're bound to the object
138
138
  bind_to_object = options.delete(:bind_to_object)
@@ -156,16 +156,16 @@ module StateMachines
156
156
  #
157
157
  # If a terminator has been configured and it matches the result from the
158
158
  # evaluated method, then the callback chain should be halted.
159
- def call(object, context = {}, *args, &block)
159
+ def call(object, context = {}, *, &)
160
160
  if @branch.matches?(object, context)
161
- run_methods(object, context, 0, *args, &block)
161
+ run_methods(object, context, 0, *, &)
162
162
  true
163
163
  else
164
164
  false
165
165
  end
166
166
  end
167
167
 
168
- private
168
+ private
169
169
 
170
170
  # Runs all of the methods configured for this callback.
171
171
  #
@@ -179,7 +179,7 @@ module StateMachines
179
179
  def run_methods(object, context = {}, index = 0, *args, &block)
180
180
  if type == :around
181
181
  current_method = @methods[index]
182
- if current_method
182
+ if current_method
183
183
  yielded = false
184
184
  evaluate_method(object, current_method, *args) do
185
185
  yielded = true
@@ -187,8 +187,8 @@ module StateMachines
187
187
  end
188
188
 
189
189
  throw :halt unless yielded
190
- else
191
- yield if block_given?
190
+ elsif block_given?
191
+ yield
192
192
  end
193
193
  else
194
194
  @methods.each do |method|
@@ -206,13 +206,12 @@ module StateMachines
206
206
  arity += 1 if arity >= 0 # Make sure the object gets passed
207
207
  arity += 1 if arity == 1 && type == :around # Make sure the block gets passed
208
208
 
209
- method = lambda { |object, *args| object.instance_exec(*args, &block) }
210
-
209
+ method = ->(object, *args) { object.instance_exec(*args, &block) }
211
210
 
212
211
  # Proxy arity to the original block
213
212
  (
214
- class << method;
215
- self;
213
+ class << method
214
+ self
216
215
  end).class_eval do
217
216
  define_method(:arity) { arity }
218
217
  end
@@ -5,7 +5,6 @@
5
5
  # * StateMachines::MacroMethods which adds the state_machine DSL to your class
6
6
  # * A set of initializers for setting state_machine defaults based on the current
7
7
  # running environment (such as within Rails)
8
- require 'state_machines/assertions'
9
8
  require 'state_machines/error'
10
9
 
11
10
  require 'state_machines/extensions'
@@ -25,7 +24,6 @@ require 'state_machines/transition_collection'
25
24
  require 'state_machines/branch'
26
25
 
27
26
  require 'state_machines/helper_module'
28
- require 'state_machines/state'
29
27
  require 'state_machines/callback'
30
28
  require 'state_machines/node_collection'
31
29
 
@@ -42,4 +40,4 @@ require 'state_machines/path_collection'
42
40
  require 'state_machines/machine'
43
41
  require 'state_machines/machine_collection'
44
42
 
45
- require 'state_machines/macro_methods'
43
+ require 'state_machines/macro_methods'
@@ -6,7 +6,7 @@ module StateMachines
6
6
  # The object that failed
7
7
  attr_reader :object
8
8
 
9
- def initialize(object, message = nil) #:nodoc:
9
+ def initialize(object, message = nil) # :nodoc:
10
10
  @object = object
11
11
 
12
12
  super(message)
@@ -49,12 +49,13 @@ module StateMachines
49
49
  # The event that was attempted to be run
50
50
  attr_reader :event
51
51
 
52
- def initialize(object, event_name) #:nodoc:
52
+ def initialize(object, event_name) # :nodoc:
53
53
  @event = event_name
54
54
 
55
55
  super(object, "#{event.inspect} is an unknown state machine event")
56
56
  end
57
57
  end
58
+
58
59
  # An invalid transition was attempted
59
60
  class InvalidTransition < Error
60
61
  # The machine attempting to be transitioned
@@ -63,7 +64,7 @@ module StateMachines
63
64
  # The current state value for the machine
64
65
  attr_reader :from
65
66
 
66
- def initialize(object, machine, event) #:nodoc:
67
+ def initialize(object, machine, event) # :nodoc:
67
68
  @machine = machine
68
69
  @from_state = machine.states.match!(object)
69
70
  @from = machine.read(object, :state)
@@ -101,7 +102,7 @@ module StateMachines
101
102
  # The set of events that failed the transition(s)
102
103
  attr_reader :events
103
104
 
104
- def initialize(object, events) #:nodoc:
105
+ def initialize(object, events) # :nodoc:
105
106
  @events = events
106
107
 
107
108
  super(object, "Cannot run events in parallel: #{events * ', '}")
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'syntax_validator'
4
+
3
5
  module StateMachines
4
6
  # Provides a set of helper methods for evaluating methods within the context
5
7
  # of an object.
@@ -52,64 +54,100 @@ module StateMachines
52
54
  # evaluate_method(person, lambda {|person| person.name}, 21) # => "John Smith"
53
55
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21) # => "John Smith is 21"
54
56
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21, 'male') # => ArgumentError: wrong number of arguments (3 for 2)
55
- def evaluate_method(object, method, *args, **kwargs, &block)
57
+ def evaluate_method(object, method, *args, **, &block)
56
58
  case method
57
- when Symbol
58
- klass = (class << object; self; end)
59
- args = [] if (klass.method_defined?(method) || klass.private_method_defined?(method)) && object.method(method).arity == 0
60
- object.send(method, *args, **kwargs, &block)
61
- when Proc
62
- args.unshift(object)
63
- arity = method.arity
64
- # Handle blocks for Procs
65
- if block_given? && arity != 0
66
- if [1, 2].include?(arity)
67
- # Force the block to be either the only argument or the 2nd one
68
- # after the object (may mean additional arguments get discarded)
69
- args = args[0, arity - 1] + [block]
70
- else
71
- # Tack the block to the end of the args
72
- args << block
73
- end
59
+ when Symbol
60
+ klass = (class << object; self; end)
61
+ args = [] if (klass.method_defined?(method) || klass.private_method_defined?(method)) && object.method(method).arity == 0
62
+ object.send(method, *args, **, &block)
63
+ when Proc
64
+ args.unshift(object)
65
+ arity = method.arity
66
+ # Handle blocks for Procs
67
+ if block_given? && arity != 0
68
+ if [1, 2].include?(arity)
69
+ # Force the block to be either the only argument or the second one
70
+ # after the object (may mean additional arguments get discarded)
71
+ args = args[0, arity - 1] + [block]
74
72
  else
75
- # These method types are only called with 0, 1, or n arguments
76
- args = args[0, arity] if [0, 1].include?(arity)
73
+ # insert the block to the end of the args
74
+ args << block
77
75
  end
76
+ elsif [0, 1].include?(arity)
77
+ # These method types are only called with 0, 1, or n arguments
78
+ args = args[0, arity]
79
+ end
78
80
 
79
81
  # Call the Proc with the arguments
80
- method.call(*args, **kwargs)
82
+ method.call(*args, **)
83
+
84
+ when Method
85
+ args.unshift(object)
86
+ arity = method.arity
81
87
 
82
- when Method
83
- args.unshift(object)
84
- arity = method.arity
88
+ # Methods handle blocks via &block, not as arguments
89
+ # Only limit arguments if necessary based on arity
90
+ args = args[0, arity] if [0, 1].include?(arity)
85
91
 
86
- # Methods handle blocks via &block, not as arguments
87
- # Only limit arguments if necessary based on arity
88
- args = args[0, arity] if [0, 1].include?(arity)
92
+ # Call the Method with the arguments and pass the block
93
+ method.call(*args, **, &block)
94
+ when String
95
+ # Input validation for string evaluation
96
+ validate_eval_string(method)
89
97
 
90
- # Call the Method with the arguments and pass the block
91
- method.call(*args, **kwargs, &block)
92
- when String
93
- if block_given?
94
- if StateMachines::Transition.pause_supported?
95
- eval(method, object.instance_eval { binding }, &block)
96
- else
97
- # Support for JRuby and Truffle Ruby, which don't support binding blocks
98
- eigen = class << object; self; end
99
- eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
98
+ if block_given?
99
+ if StateMachines::Transition.pause_supported?
100
+ eval(method, object.instance_eval { binding }, &block)
101
+ else
102
+ # Support for JRuby and Truffle Ruby, which don't support binding blocks
103
+ # Need to check with @headius, if jruby 10 does now.
104
+ eigen = class << object; self; end
105
+ eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
100
106
  def __temp_eval_method__(*args, &b)
101
107
  #{method}
102
108
  end
103
- RUBY
104
- result = object.__temp_eval_method__(*args, &block)
105
- eigen.send(:remove_method, :__temp_eval_method__)
106
- result
107
- end
108
- else
109
- eval(method, object.instance_eval { binding })
109
+ RUBY
110
+ result = object.__temp_eval_method__(*args, &block)
111
+ eigen.send(:remove_method, :__temp_eval_method__)
112
+ result
110
113
  end
111
114
  else
112
- raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
115
+ eval(method, object.instance_eval { binding })
116
+ end
117
+ else
118
+ raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Validates string input before eval to prevent code injection
125
+ # This is a basic safety check - not foolproof security
126
+ def validate_eval_string(method_string)
127
+ # Check for obviously dangerous patterns
128
+ dangerous_patterns = [
129
+ /`.*`/, # Backticks (shell execution)
130
+ /system\s*\(/, # System calls
131
+ /exec\s*\(/, # Exec calls
132
+ /eval\s*\(/, # Nested eval
133
+ /require\s+['"]/, # Require statements
134
+ /load\s+['"]/, # Load statements
135
+ /File\./, # File operations
136
+ /IO\./, # IO operations
137
+ /Dir\./, # Directory operations
138
+ /Kernel\./ # Kernel operations
139
+ ]
140
+
141
+ dangerous_patterns.each do |pattern|
142
+ raise SecurityError, "Potentially dangerous code detected in eval string: #{method_string.inspect}" if method_string.match?(pattern)
143
+ end
144
+
145
+ # Basic syntax validation - but allow yield since it's valid in block context
146
+ begin
147
+ test_code = method_string.include?('yield') ? "def dummy_method; #{method_string}; end" : method_string
148
+ SyntaxValidator.validate!(test_code, '(eval)')
149
+ rescue SyntaxError => e
150
+ raise ArgumentError, "Invalid Ruby syntax in eval string: #{e.message}"
113
151
  end
114
152
  end
115
153
  end