state_machines 0.20.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 +124 -13
- data/lib/state_machines/branch.rb +12 -13
- data/lib/state_machines/callback.rb +11 -12
- data/lib/state_machines/core.rb +0 -1
- data/lib/state_machines/error.rb +5 -4
- data/lib/state_machines/eval_helpers.rb +83 -45
- data/lib/state_machines/event.rb +23 -26
- 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 +73 -617
- data/lib/state_machines/machine_collection.rb +18 -14
- 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 +18 -17
- data/lib/state_machines/path.rb +2 -4
- data/lib/state_machines/path_collection.rb +2 -3
- data/lib/state_machines/state.rb +6 -5
- data/lib/state_machines/state_collection.rb +3 -3
- data/lib/state_machines/state_context.rb +6 -7
- 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 +290 -27
- data/lib/state_machines/transition.rb +43 -41
- data/lib/state_machines/transition_collection.rb +22 -25
- data/lib/state_machines/version.rb +1 -1
- metadata +23 -9
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module HelperGenerators
|
6
|
+
protected
|
7
|
+
|
8
|
+
# Adds helper methods for interacting with the state machine, including
|
9
|
+
# for states, events, and transitions
|
10
|
+
def define_helpers
|
11
|
+
define_state_accessor
|
12
|
+
define_state_predicate
|
13
|
+
define_event_helpers
|
14
|
+
define_path_helpers
|
15
|
+
define_action_helpers if define_action_helpers?
|
16
|
+
define_name_helpers
|
17
|
+
end
|
18
|
+
|
19
|
+
# Defines the initial values for state machine attributes. Static values
|
20
|
+
# are set prior to the original initialize method and dynamic values are
|
21
|
+
# set *after* the initialize method in case it is dependent on it.
|
22
|
+
def define_state_initializer
|
23
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
24
|
+
def initialize(*)
|
25
|
+
self.class.state_machines.initialize_states(self) { super }
|
26
|
+
end
|
27
|
+
END_EVAL
|
28
|
+
end
|
29
|
+
|
30
|
+
# Adds reader/writer methods for accessing the state attribute
|
31
|
+
def define_state_accessor
|
32
|
+
attribute = self.attribute
|
33
|
+
|
34
|
+
@helper_modules[:instance].class_eval { attr_reader attribute } unless owner_class_ancestor_has_method?(:instance, attribute)
|
35
|
+
@helper_modules[:instance].class_eval { attr_writer attribute } unless owner_class_ancestor_has_method?(:instance, "#{attribute}=")
|
36
|
+
end
|
37
|
+
|
38
|
+
# Adds predicate method to the owner class for determining the name of the
|
39
|
+
# current state
|
40
|
+
def define_state_predicate
|
41
|
+
call_super = owner_class_ancestor_has_method?(:instance, "#{name}?") ? true : false
|
42
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
43
|
+
def #{name}?(*args)
|
44
|
+
args.empty? && (#{call_super} || defined?(super)) ? super : self.class.state_machine(#{name.inspect}).states.matches?(self, *args)
|
45
|
+
end
|
46
|
+
END_EVAL
|
47
|
+
end
|
48
|
+
|
49
|
+
# Adds helper methods for getting information about this state machine's
|
50
|
+
# events
|
51
|
+
def define_event_helpers
|
52
|
+
# Gets the events that are allowed to fire on the current object
|
53
|
+
define_helper(:instance, attribute(:events)) do |machine, object, *args|
|
54
|
+
machine.events.valid_for(object, *args).map(&:name)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Gets the next possible transitions that can be run on the current
|
58
|
+
# object
|
59
|
+
define_helper(:instance, attribute(:transitions)) do |machine, object, *args|
|
60
|
+
machine.events.transitions_for(object, *args)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Fire an arbitrary event for this machine
|
64
|
+
define_helper(:instance, "fire_#{attribute(:event)}") do |machine, object, event, *args|
|
65
|
+
machine.events.fetch(event).fire(object, *args)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Add helpers for tracking the event / transition to invoke when the
|
69
|
+
# action is called
|
70
|
+
return unless action
|
71
|
+
|
72
|
+
event_attribute = attribute(:event)
|
73
|
+
define_helper(:instance, event_attribute) do |machine, object|
|
74
|
+
# Interpret non-blank events as present
|
75
|
+
event = machine.read(object, :event, true)
|
76
|
+
event && !(event.respond_to?(:empty?) && event.empty?) ? event.to_sym : nil
|
77
|
+
end
|
78
|
+
|
79
|
+
# A roundabout way of writing the attribute is used here so that
|
80
|
+
# integrations can hook into this modification
|
81
|
+
define_helper(:instance, "#{event_attribute}=") do |machine, object, value|
|
82
|
+
machine.write(object, :event, value, true)
|
83
|
+
end
|
84
|
+
|
85
|
+
event_transition_attribute = attribute(:event_transition)
|
86
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
87
|
+
protected; attr_accessor #{event_transition_attribute.inspect}
|
88
|
+
END_EVAL
|
89
|
+
end
|
90
|
+
|
91
|
+
# Adds helper methods for getting information about this state machine's
|
92
|
+
# available transition paths
|
93
|
+
def define_path_helpers
|
94
|
+
# Gets the paths of transitions available to the current object
|
95
|
+
define_helper(:instance, attribute(:paths)) do |machine, object, *args|
|
96
|
+
machine.paths_for(object, *args)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Adds helper methods for accessing naming information about states and
|
101
|
+
# events on the owner class
|
102
|
+
def define_name_helpers
|
103
|
+
# Gets the humanized version of a state
|
104
|
+
define_helper(:class, "human_#{attribute(:name)}") do |machine, klass, state|
|
105
|
+
machine.states.fetch(state).human_name(klass)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Gets the humanized version of an event
|
109
|
+
define_helper(:class, "human_#{attribute(:event_name)}") do |machine, klass, event|
|
110
|
+
machine.events.fetch(event).human_name(klass)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Gets the state name for the current value
|
114
|
+
define_helper(:instance, attribute(:name)) do |machine, object|
|
115
|
+
machine.states.match!(object).name
|
116
|
+
end
|
117
|
+
|
118
|
+
# Gets the human state name for the current value
|
119
|
+
define_helper(:instance, "human_#{attribute(:name)}") do |machine, object|
|
120
|
+
machine.states.match!(object).human_name(object.class)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Integration
|
6
|
+
# Marks the given object as invalid with the given message.
|
7
|
+
#
|
8
|
+
# By default, this is a no-op.
|
9
|
+
def invalidate(_object, _attribute, _message, _values = []); end
|
10
|
+
|
11
|
+
# Gets a description of the errors for the given object. This is used to
|
12
|
+
# provide more detailed information when an InvalidTransition exception is
|
13
|
+
# raised.
|
14
|
+
def errors_for(_object)
|
15
|
+
''
|
16
|
+
end
|
17
|
+
|
18
|
+
# Resets any errors previously added when invalidating the given object.
|
19
|
+
#
|
20
|
+
# By default, this is a no-op.
|
21
|
+
def reset(_object); end
|
22
|
+
|
23
|
+
# Generates a user-friendly name for the given message.
|
24
|
+
def generate_message(name, values = [])
|
25
|
+
format(@messages[name] || @messages[:invalid_transition] || default_messages[name] || default_messages[:invalid_transition], state: values.first)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Runs a transaction, yielding the given block.
|
29
|
+
#
|
30
|
+
# By default, this is a no-op.
|
31
|
+
def within_transaction(object, &)
|
32
|
+
if use_transactions && respond_to?(:transaction, true)
|
33
|
+
transaction(object, &)
|
34
|
+
else
|
35
|
+
yield
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
protected
|
40
|
+
|
41
|
+
# Runs additional initialization hooks. By default, this is a no-op.
|
42
|
+
def after_initialize; end
|
43
|
+
|
44
|
+
# Always yields
|
45
|
+
def transaction(_object)
|
46
|
+
yield
|
47
|
+
end
|
48
|
+
|
49
|
+
# Gets the initial attribute value defined by the owner class (outside of
|
50
|
+
# the machine's definition). By default, this is always nil.
|
51
|
+
def owner_class_attribute_default
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
|
55
|
+
# Checks whether the given state matches the attribute default specified
|
56
|
+
# by the owner class
|
57
|
+
def owner_class_attribute_default_matches?(state)
|
58
|
+
state.matches?(owner_class_attribute_default)
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Gets the default messages that can be used in the machine for invalid
|
64
|
+
# transitions.
|
65
|
+
def default_messages
|
66
|
+
{ invalid_transition: '%<state>s cannot transition via "%<event>s"' }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Parsing
|
6
|
+
private
|
7
|
+
|
8
|
+
# Parses callback arguments for backward compatibility with both positional
|
9
|
+
# and keyword argument styles. Supports Ruby 3.2+ keyword arguments while
|
10
|
+
# maintaining full backward compatibility with the legacy API.
|
11
|
+
def parse_callback_arguments(args, options)
|
12
|
+
# Handle legacy positional args: before_transition(:method1, :method2, from: :state)
|
13
|
+
if args.any?
|
14
|
+
# Extract hash options from the end of args if present
|
15
|
+
parsed_options = args.last.is_a?(Hash) ? args.pop.dup : {}
|
16
|
+
|
17
|
+
# Merge any additional keyword options
|
18
|
+
parsed_options.merge!(options) if options.any?
|
19
|
+
|
20
|
+
# Remaining args become the :do option (method names to call)
|
21
|
+
parsed_options[:do] = args if args.any?
|
22
|
+
|
23
|
+
parsed_options
|
24
|
+
else
|
25
|
+
# Pure keyword argument style: before_transition(from: :state, to: :other, do: :method)
|
26
|
+
options.dup
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Adds a new transition callback of the given type.
|
31
|
+
def add_callback(type, options, &)
|
32
|
+
callbacks[type == :around ? :before : type] << callback = Callback.new(type, options, &)
|
33
|
+
add_states(callback.known_states)
|
34
|
+
callback
|
35
|
+
end
|
36
|
+
|
37
|
+
# Tracks the given set of states in the list of all known states for
|
38
|
+
# this machine
|
39
|
+
def add_states(new_states)
|
40
|
+
new_states.map do |new_state|
|
41
|
+
# Check for other states that use a different class type for their name.
|
42
|
+
# This typically prevents string / symbol misuse.
|
43
|
+
if new_state && (conflict = states.detect { |state| state.name && state.name.class != new_state.class })
|
44
|
+
raise ArgumentError, "#{new_state.inspect} state defined as #{new_state.class}, #{conflict.name.inspect} defined as #{conflict.name.class}; all states must be consistent"
|
45
|
+
end
|
46
|
+
|
47
|
+
unless (state = states[new_state])
|
48
|
+
states << state = State.new(self, new_state)
|
49
|
+
|
50
|
+
# Copy states over to sibling machines
|
51
|
+
sibling_machines.each { |machine| machine.states << state }
|
52
|
+
end
|
53
|
+
|
54
|
+
state
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Tracks the given set of events in the list of all known events for
|
59
|
+
# this machine
|
60
|
+
def add_events(new_events)
|
61
|
+
new_events.map do |new_event|
|
62
|
+
# Check for other states that use a different class type for their name.
|
63
|
+
# This typically prevents string / symbol misuse.
|
64
|
+
if (conflict = events.detect { |event| event.name.class != new_event.class })
|
65
|
+
raise ArgumentError, "#{new_event.inspect} event defined as #{new_event.class}, #{conflict.name.inspect} defined as #{conflict.name.class}; all events must be consistent"
|
66
|
+
end
|
67
|
+
|
68
|
+
unless (event = events[new_event])
|
69
|
+
events << event = Event.new(self, new_event)
|
70
|
+
end
|
71
|
+
|
72
|
+
event
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Rendering
|
6
|
+
# Gets the renderer for this machine.
|
7
|
+
def renderer
|
8
|
+
@renderer ||= StdioRenderer.new
|
9
|
+
end
|
10
|
+
|
11
|
+
# Generates a visual representation of this machine for a given format.
|
12
|
+
def draw(**)
|
13
|
+
renderer.draw(self, **)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Scoping
|
6
|
+
protected
|
7
|
+
|
8
|
+
# Defines the with/without scope helpers for this attribute. Both the
|
9
|
+
# singular and plural versions of the attribute are defined for each
|
10
|
+
# scope helper. A custom plural can be specified if it cannot be
|
11
|
+
# automatically determined by either calling +pluralize+ on the attribute
|
12
|
+
# name or adding an "s" to the end of the name.
|
13
|
+
def define_scopes(custom_plural = nil)
|
14
|
+
plural = custom_plural || pluralize(name)
|
15
|
+
|
16
|
+
%i[with without].each do |kind|
|
17
|
+
[name, plural].map(&:to_s).uniq.each do |suffix|
|
18
|
+
method = "#{kind}_#{suffix}"
|
19
|
+
|
20
|
+
next unless (scope = send("create_#{kind}_scope", method))
|
21
|
+
|
22
|
+
# Converts state names to their corresponding values so that they
|
23
|
+
# can be looked up properly
|
24
|
+
define_helper(:class, method) do |machine, klass, *states|
|
25
|
+
run_scope(scope, machine, klass, states)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Creates a scope for finding objects *with* a particular value or values
|
32
|
+
# for the attribute.
|
33
|
+
#
|
34
|
+
# By default, this is a no-op.
|
35
|
+
def create_with_scope(name); end
|
36
|
+
|
37
|
+
# Creates a scope for finding objects *without* a particular value or
|
38
|
+
# values for the attribute.
|
39
|
+
#
|
40
|
+
# By default, this is a no-op.
|
41
|
+
def create_without_scope(name); end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module StateMethods
|
6
|
+
# Gets the initial state of the machine for the given object. If a dynamic
|
7
|
+
# initial state was configured for this machine, then the object will be
|
8
|
+
# passed into the lambda block to help determine the actual state.
|
9
|
+
def initial_state(object)
|
10
|
+
states.fetch(dynamic_initial_state? ? evaluate_method(object, @initial_state) : @initial_state) if instance_variable_defined?(:@initial_state)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Whether a dynamic initial state is being used in the machine
|
14
|
+
def dynamic_initial_state?
|
15
|
+
instance_variable_defined?(:@initial_state) && @initial_state.is_a?(Proc)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Initializes the state on the given object. Initial values are only set if
|
19
|
+
# the machine's attribute hasn't been previously initialized.
|
20
|
+
#
|
21
|
+
# Configuration options:
|
22
|
+
# * <tt>:force</tt> - Whether to initialize the state regardless of its
|
23
|
+
# current value
|
24
|
+
# * <tt>:to</tt> - A hash to set the initial value in instead of writing
|
25
|
+
# directly to the object
|
26
|
+
def initialize_state(object, options = {})
|
27
|
+
state = initial_state(object)
|
28
|
+
return unless state && (options[:force] || initialize_state?(object))
|
29
|
+
|
30
|
+
value = state.value
|
31
|
+
|
32
|
+
if (hash = options[:to])
|
33
|
+
hash[attribute.to_s] = value
|
34
|
+
else
|
35
|
+
write(object, :state, value)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Customizes the definition of one or more states in the machine.
|
40
|
+
def state(*names, &)
|
41
|
+
options = names.last.is_a?(Hash) ? names.pop : {}
|
42
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :value, :cache, :if, :human_name)
|
43
|
+
|
44
|
+
# Store the context so that it can be used for / matched against any state
|
45
|
+
# that gets added
|
46
|
+
@states.context(names, &) if block_given?
|
47
|
+
|
48
|
+
if names.first.is_a?(Matcher)
|
49
|
+
# Add any states referenced in the matcher. When matchers are used,
|
50
|
+
# states are not allowed to be configured.
|
51
|
+
raise ArgumentError, "Cannot configure states when using matchers (using #{options.inspect})" if options.any?
|
52
|
+
|
53
|
+
states = add_states(names.first.values)
|
54
|
+
else
|
55
|
+
states = add_states(names)
|
56
|
+
|
57
|
+
# Update the configuration for the state(s)
|
58
|
+
states.each do |state|
|
59
|
+
if options.include?(:value)
|
60
|
+
state.value = options[:value]
|
61
|
+
self.states.update(state)
|
62
|
+
end
|
63
|
+
|
64
|
+
state.human_name = options[:human_name] if options.include?(:human_name)
|
65
|
+
state.cache = options[:cache] if options.include?(:cache)
|
66
|
+
state.matcher = options[:if] if options.include?(:if)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
states.length == 1 ? states.first : states
|
71
|
+
end
|
72
|
+
|
73
|
+
alias other_states state
|
74
|
+
|
75
|
+
# Gets the current value stored in the given object's attribute.
|
76
|
+
def read(object, attribute, ivar = false)
|
77
|
+
attribute = self.attribute(attribute)
|
78
|
+
if ivar
|
79
|
+
object.instance_variable_defined?(:"@#{attribute}") ? object.instance_variable_get("@#{attribute}") : nil
|
80
|
+
else
|
81
|
+
object.send(attribute)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Sets a new value in the given object's attribute.
|
86
|
+
def write(object, attribute, value, ivar = false)
|
87
|
+
attribute = self.attribute(attribute)
|
88
|
+
ivar ? object.instance_variable_set(:"@#{attribute}", value) : object.send("#{attribute}=", value)
|
89
|
+
end
|
90
|
+
|
91
|
+
protected
|
92
|
+
|
93
|
+
# Determines if the machine's attribute needs to be initialized. This
|
94
|
+
# will only be true if the machine's attribute is blank.
|
95
|
+
def initialize_state?(object)
|
96
|
+
value = read(object, :state)
|
97
|
+
(value.nil? || (value.respond_to?(:empty?) && value.empty?)) && !states[value, :value]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Utilities
|
6
|
+
protected
|
7
|
+
|
8
|
+
# Looks up other machines that have been defined in the owner class and
|
9
|
+
# are targeting the same attribute as this machine. When accessing
|
10
|
+
# sibling machines, they will be automatically copied for the current
|
11
|
+
# class if they haven't been already. This ensures that any configuration
|
12
|
+
# changes made to the sibling machines only affect this class and not any
|
13
|
+
# base class that may have originally defined the machine.
|
14
|
+
def sibling_machines
|
15
|
+
owner_class.state_machines.each_with_object([]) do |(name, machine), machines|
|
16
|
+
machines << (owner_class.state_machine(name) {}) if machine.attribute == attribute && machine != self
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Looks up the ancestor class that has the given method defined. This
|
21
|
+
# is used to find the method owner which is used to determine where to
|
22
|
+
# define new methods.
|
23
|
+
def owner_class_ancestor_has_method?(scope, method)
|
24
|
+
return false unless owner_class_has_method?(scope, method)
|
25
|
+
|
26
|
+
superclasses = owner_class.ancestors.select { |ancestor| ancestor.is_a?(Class) }[1..]
|
27
|
+
|
28
|
+
if scope == :class
|
29
|
+
current = owner_class.singleton_class
|
30
|
+
superclass = superclasses.first
|
31
|
+
else
|
32
|
+
current = owner_class
|
33
|
+
superclass = owner_class.superclass
|
34
|
+
end
|
35
|
+
|
36
|
+
# Generate the list of modules that *only* occur in the owner class, but
|
37
|
+
# were included *prior* to the helper modules, in addition to the
|
38
|
+
# superclasses
|
39
|
+
ancestors = current.ancestors - superclass.ancestors + superclasses
|
40
|
+
helper_module_index = ancestors.index(@helper_modules[scope])
|
41
|
+
ancestors = helper_module_index ? ancestors[helper_module_index..].reverse : ancestors.reverse
|
42
|
+
|
43
|
+
# Search for for the first ancestor that defined this method
|
44
|
+
ancestors.detect do |ancestor|
|
45
|
+
ancestor = ancestor.singleton_class if scope == :class && ancestor.is_a?(Class)
|
46
|
+
ancestor.method_defined?(method) || ancestor.private_method_defined?(method)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Determines whether the given method is defined in the owner class or
|
51
|
+
# in a superclass.
|
52
|
+
def owner_class_has_method?(scope, method)
|
53
|
+
target = scope == :class ? owner_class.singleton_class : owner_class
|
54
|
+
target.method_defined?(method) || target.private_method_defined?(method)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Pluralizes the given word using #pluralize (if available) or simply
|
58
|
+
# adding an "s" to the end of the word
|
59
|
+
def pluralize(word)
|
60
|
+
word = word.to_s
|
61
|
+
if word.respond_to?(:pluralize)
|
62
|
+
word.pluralize
|
63
|
+
else
|
64
|
+
"#{word}s"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Generates the results for the given scope based on one or more states to
|
69
|
+
# filter by
|
70
|
+
def run_scope(scope, machine, klass, states)
|
71
|
+
values = states.flatten.compact.map { |state| machine.states.fetch(state).value }
|
72
|
+
scope.call(klass, values)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Adds sibling machine configurations to the current machine. This
|
76
|
+
# will add states from other machines that have the same attribute.
|
77
|
+
def add_sibling_machine_configs
|
78
|
+
# Add existing states
|
79
|
+
sibling_machines.each do |machine|
|
80
|
+
machine.states.each { |state| states << state unless states[state.name] }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Validation
|
6
|
+
# Frozen constant to avoid repeated array allocations
|
7
|
+
DANGEROUS_PATTERNS = [
|
8
|
+
/`.*`/, # Backticks (shell execution)
|
9
|
+
/system\s*\(/, # System calls
|
10
|
+
/exec\s*\(/, # Exec calls
|
11
|
+
/eval\s*\(/, # Nested eval
|
12
|
+
/require\s+['"]/, # Require statements
|
13
|
+
/load\s+['"]/, # Load statements
|
14
|
+
/File\./, # File operations
|
15
|
+
/IO\./, # IO operations
|
16
|
+
/Dir\./, # Directory operations
|
17
|
+
/Kernel\./ # Kernel operations
|
18
|
+
].freeze
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
# Validates string input before eval to prevent code injection
|
23
|
+
# This is a basic safety check - not foolproof security
|
24
|
+
def validate_eval_string(method_string)
|
25
|
+
# Check for obviously dangerous patterns
|
26
|
+
DANGEROUS_PATTERNS.each do |pattern|
|
27
|
+
raise SecurityError, "Potentially dangerous code detected in eval string: #{method_string.inspect}" if method_string.match?(pattern)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Basic syntax validation (cross-platform)
|
31
|
+
begin
|
32
|
+
SyntaxValidator.validate!(method_string, '(eval)')
|
33
|
+
rescue SyntaxError => e
|
34
|
+
raise ArgumentError, "Invalid Ruby syntax in eval string: #{e.message}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|