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.
- checksums.yaml +4 -4
- data/README.md +177 -2
- data/lib/state_machines/branch.rb +16 -15
- data/lib/state_machines/callback.rb +11 -12
- data/lib/state_machines/core.rb +1 -3
- data/lib/state_machines/error.rb +5 -4
- data/lib/state_machines/eval_helpers.rb +83 -45
- data/lib/state_machines/event.rb +37 -27
- data/lib/state_machines/event_collection.rb +4 -5
- data/lib/state_machines/extensions.rb +5 -5
- data/lib/state_machines/helper_module.rb +1 -1
- data/lib/state_machines/integrations/base.rb +1 -1
- data/lib/state_machines/integrations.rb +11 -14
- data/lib/state_machines/machine/action_hooks.rb +53 -0
- data/lib/state_machines/machine/callbacks.rb +59 -0
- data/lib/state_machines/machine/class_methods.rb +25 -11
- data/lib/state_machines/machine/configuration.rb +124 -0
- data/lib/state_machines/machine/event_methods.rb +59 -0
- data/lib/state_machines/machine/helper_generators.rb +125 -0
- data/lib/state_machines/machine/integration.rb +70 -0
- data/lib/state_machines/machine/parsing.rb +77 -0
- data/lib/state_machines/machine/rendering.rb +17 -0
- data/lib/state_machines/machine/scoping.rb +44 -0
- data/lib/state_machines/machine/state_methods.rb +101 -0
- data/lib/state_machines/machine/utilities.rb +85 -0
- data/lib/state_machines/machine/validation.rb +39 -0
- data/lib/state_machines/machine.rb +75 -618
- data/lib/state_machines/machine_collection.rb +21 -15
- data/lib/state_machines/macro_methods.rb +2 -2
- data/lib/state_machines/matcher.rb +6 -6
- data/lib/state_machines/matcher_helpers.rb +1 -1
- data/lib/state_machines/node_collection.rb +21 -18
- data/lib/state_machines/options_validator.rb +72 -0
- data/lib/state_machines/path.rb +5 -5
- data/lib/state_machines/path_collection.rb +5 -4
- data/lib/state_machines/state.rb +29 -11
- data/lib/state_machines/state_collection.rb +3 -3
- data/lib/state_machines/state_context.rb +9 -8
- data/lib/state_machines/stdio_renderer.rb +16 -16
- data/lib/state_machines/syntax_validator.rb +57 -0
- data/lib/state_machines/test_helper.rb +568 -0
- data/lib/state_machines/transition.rb +43 -41
- data/lib/state_machines/transition_collection.rb +25 -26
- data/lib/state_machines/version.rb +1 -1
- metadata +25 -10
- data/lib/state_machines/assertions.rb +0 -42
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5c732f34387da18f4dc1c3d78cad1fbb111cc88d06d9fe3edcda912adc5e655a
|
4
|
+
data.tar.gz: ef0079a89a1b0e4ddea45dd4a79e11de12740d4eb0c2aa98ad95b5ca7ae3eab8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d71a5bd20b0de0b19e4684b4251954d0cf648e5454b57af96555372bbdee59a9ac855bb27d182e018f87ffa0525b5cd3daff5c3ce53c483f3dec4954d076a331
|
7
|
+
data.tar.gz: 179e0cb6c2a31d14c4078820d254ce35e26d9b5aaa2646e4766b842127411cf49e5ad697d0a764ba79a0036bce5dc288ef113b8bef57c3cac64de8816a23f49b
|
data/README.md
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|

|
2
|
-
[](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 = {})
|
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 - [
|
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
|
-
[
|
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
|
-
|
118
|
+
StateMachines::OptionsValidator.assert_valid_keys!(query, :from, :to, :on, :guard)
|
118
119
|
|
119
|
-
|
120
|
-
|
121
|
-
|
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
|
-
|
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
|
-
|
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
|
-
[
|
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
|
-
|
184
|
-
|
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 [
|
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 = {},
|
159
|
+
def call(object, context = {}, *, &)
|
160
160
|
if @branch.matches?(object, context)
|
161
|
-
run_methods(object, context, 0,
|
161
|
+
run_methods(object, context, 0, *, &)
|
162
162
|
true
|
163
163
|
else
|
164
164
|
false
|
165
165
|
end
|
166
166
|
end
|
167
167
|
|
168
|
-
|
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
|
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
|
-
|
191
|
-
yield
|
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 =
|
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
|
data/lib/state_machines/core.rb
CHANGED
@@ -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'
|
data/lib/state_machines/error.rb
CHANGED
@@ -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)
|
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)
|
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)
|
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)
|
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,
|
57
|
+
def evaluate_method(object, method, *args, **, &block)
|
56
58
|
case method
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
-
#
|
76
|
-
args
|
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, **
|
82
|
+
method.call(*args, **)
|
83
|
+
|
84
|
+
when Method
|
85
|
+
args.unshift(object)
|
86
|
+
arity = method.arity
|
81
87
|
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
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
|