pushdown 0.1.0.pre.20210714190141 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,174 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require 'pluggability'
5
+ require 'loggability'
6
+
7
+ require 'pushdown' unless defined?( Pushdown )
8
+
9
+
10
+ # A componented state object in a Pushdown automaton
11
+ class Pushdown::State
12
+ extend Loggability
13
+
14
+ # Loggability API -- log to the pushdown logger
15
+ log_to :pushdown
16
+
17
+ # Don't allow instantation of the abstract class
18
+ private_class_method :new
19
+
20
+ ##
21
+ # Allow introspection on declared transitions
22
+ singleton_class.attr_reader :transitions
23
+
24
+
25
+ ### Inheritance callback -- allow subclasses to be instantiated, and add some
26
+ ### class-instance data to them.
27
+ def self::inherited( subclass )
28
+ super
29
+
30
+ subclass.public_class_method( :new )
31
+ subclass.instance_variable_set( :@transitions, {} )
32
+ end
33
+
34
+
35
+ #
36
+ # Transition declarations
37
+ #
38
+
39
+ ### Register a transition +type+ declaration method.
40
+ def self::register_transition( type )
41
+ type = type.to_sym
42
+ meth = lambda do |transition_name, *args|
43
+ self.transitions[ transition_name ] = [ type, *args ]
44
+ end
45
+
46
+ method_name = "transition_%s" % [ type ]
47
+ self.log.info "Setting up transition declaration method %p" % [ method_name ]
48
+ define_singleton_method( method_name, &meth )
49
+ end
50
+
51
+
52
+ ### Return the transition's type as a lowercase Symbol, such as that specified
53
+ ### in transition declarations.
54
+ def self::type_name
55
+ class_name = self.name or return :anonymous
56
+ return class_name.sub( /.*::/, '' ).downcase.to_sym
57
+ end
58
+
59
+
60
+ ### Set up new States with an optional +data+ object.
61
+ def initialize( data=nil )
62
+ @data = data
63
+ end
64
+
65
+
66
+ ######
67
+ public
68
+ ######
69
+
70
+ ##
71
+ # The state data object that was used to create the State (if any)
72
+ attr_reader :data
73
+
74
+
75
+ #
76
+ # Stack callbacks
77
+ #
78
+
79
+ ### Stack callback -- called when the state is added to the stack.
80
+ def on_start
81
+ return nil # no-op
82
+ end
83
+
84
+
85
+ ### Stack callback -- called when the state is removed from the stack.
86
+ def on_stop
87
+ return nil # no-op
88
+ end
89
+
90
+
91
+ ### Stack callback -- called when another state is pushed over this one.
92
+ def on_pause
93
+ return nil # no-op
94
+ end
95
+
96
+
97
+ ### Stack callback -- called when another state is popped off from in front of
98
+ ### this one, making it the current state.
99
+ def on_resume
100
+ return nil # no-op
101
+ end
102
+
103
+
104
+ #
105
+ # Event callbacks
106
+ #
107
+
108
+ ### Event callback -- called by the automaton when its #on_<stackname>_event method
109
+ ### is called. This method can return a Transition or a Symbol which maps to one.
110
+ def on_event( event, *args )
111
+ return nil # no-op
112
+ end
113
+
114
+
115
+ #
116
+ # Interval callbacks
117
+ #
118
+
119
+ ### State callback -- interval callback called when the state is the current
120
+ ### one. This method can return a Transition or a Symbol which maps to one.
121
+ def update( *data )
122
+ return nil # no-op
123
+ end
124
+
125
+
126
+ ### State callback -- interval callback called when the state is on the stack,
127
+ ### even when the state is not the current one.
128
+ def shadow_update( *data )
129
+ return nil # no-op
130
+ end
131
+
132
+
133
+ #
134
+ # Introspection/information
135
+ #
136
+
137
+ ### Return the transition's type as a lowercase Symbol, such as that specified
138
+ ### in transition declarations.
139
+ def type_name
140
+ return self.class.type_name
141
+ end
142
+
143
+
144
+ ### Return a description of the State as an engine phrase.
145
+ def description
146
+ return "%#x" % [ self.class.object_id ] unless self.class.name
147
+ return self.class.name.sub( /.*::/, '' ).
148
+ gsub( /([A-Z])([A-Z])/ ) { "#$1 #$2" }.
149
+ gsub( /([a-z])([A-Z])/ ) { "#$1 #$2" }.downcase
150
+ end
151
+
152
+
153
+ ### Create a new instance of Pushdown::Transition named +transition_name+ that
154
+ ### has been declared using one of the Transition Declaration methods.
155
+ def transition( transition_name, automaton, stack_name )
156
+ self.log.debug "Looking up the %p transition for %p via %p" %
157
+ [ transition_name, self, automaton ]
158
+
159
+ transition_type, state_class_name = self.class.transitions[ transition_name ]
160
+ raise "no such transition %p for %p" % [ transition_name, self.class ] unless transition_type
161
+
162
+ if state_class_name
163
+ state_class = automaton.class.pushdown_state_class( stack_name, state_class_name )
164
+ state_data = self.data
165
+
166
+ return Pushdown::Transition.
167
+ create( transition_type, transition_name, state_class, state_data )
168
+ else
169
+ return Pushdown::Transition.create( transition_type, transition_name )
170
+ end
171
+ end
172
+
173
+ end # class Pushdown::State
174
+
@@ -0,0 +1,35 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require 'pushdown/transition' unless defined?( Pushdown::Transition )
5
+ require 'pushdown/exceptions'
6
+
7
+
8
+ # A push transition -- add an instance of a given State to the top of the state
9
+ # stack.
10
+ class Pushdown::Transition::Pop < Pushdown::Transition
11
+
12
+
13
+ ######
14
+ public
15
+ ######
16
+
17
+ ##
18
+ # Return the state that was popped
19
+ attr_reader :popped_state
20
+
21
+
22
+ ### Apply the transition to the given +stack+.
23
+ def apply( stack )
24
+ raise Pushdown::TransitionError, "can't pop from an empty stack" if stack.empty?
25
+ raise Pushdown::TransitionError, "can't pop the only state on the stack" if stack.length == 1
26
+
27
+ self.log.debug "popping a state"
28
+ @popped_state = stack.pop
29
+ @popped_state.on_stop
30
+ stack.last.on_resume
31
+
32
+ return stack
33
+ end
34
+
35
+ end # class Pushdown::Transition::Pop
@@ -0,0 +1,47 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require 'pushdown/transition' unless defined?( Pushdown::Transition )
5
+
6
+
7
+ # A push transition -- add an instance of a given State to the top of the state
8
+ # stack.
9
+ class Pushdown::Transition::Push < Pushdown::Transition
10
+
11
+
12
+ ### Create a transition that will Push an instance of the given +state_class+ to
13
+ ### the stack.
14
+ def initialize( name, state_class, data=nil )
15
+ super( name )
16
+
17
+ @state_class = state_class
18
+ @data = data
19
+ end
20
+
21
+
22
+ ######
23
+ public
24
+ ######
25
+
26
+ ##
27
+ # The State to push to.
28
+ attr_reader :state_class
29
+
30
+ ##
31
+ # The data object to pass to the #state_class's constructor
32
+ attr_reader :data
33
+
34
+
35
+ ### Apply the transition to the given +stack+.
36
+ def apply( stack )
37
+ state = self.state_class.new( self.data )
38
+
39
+ self.log.debug "pushing a new state: %p" % [ state ]
40
+ stack.last.on_pause if stack.last
41
+ stack.push( state )
42
+ state.on_start
43
+
44
+ return stack
45
+ end
46
+
47
+ end # class Pushdown::Transition::Push
@@ -0,0 +1,49 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require 'pushdown/transition' unless defined?( Pushdown::Transition )
5
+
6
+
7
+ # A replace transition -- remove all currents states from the stack and add a
8
+ # different one.
9
+ class Pushdown::Transition::Replace < Pushdown::Transition
10
+
11
+ ### Create a transition that will Replace all the states on the current stack
12
+ ### with an instance of the given +state_class+.
13
+ def initialize( name, state_class, data=nil )
14
+ super( name )
15
+
16
+ @state_class = state_class
17
+ @data = data
18
+ end
19
+
20
+
21
+ ######
22
+ public
23
+ ######
24
+
25
+ ##
26
+ # The State to replace the stack members with.
27
+ attr_reader :state_class
28
+
29
+ ##
30
+ # The data object to pass to the #state_class's constructor
31
+ attr_reader :data
32
+
33
+
34
+ ### Apply the transition to the given +stack+.
35
+ def apply( stack )
36
+ state = self.state_class.new( self.data )
37
+
38
+ self.log.debug "replacing current state with a new state: %p" % [ state ]
39
+ while ( old_state = stack.pop )
40
+ old_state.on_stop
41
+ end
42
+
43
+ stack.push( state )
44
+ state.on_start
45
+
46
+ return stack
47
+ end
48
+
49
+ end # class Pushdown::Transition::Replace
@@ -0,0 +1,50 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require 'pushdown/transition' unless defined?( Pushdown::Transition )
5
+
6
+
7
+ # A switch transition -- remove the current state from the stack and add a
8
+ # different one.
9
+ class Pushdown::Transition::Switch < Pushdown::Transition
10
+
11
+ ### Create a transition that will Switch the current State with an instance of
12
+ ### the given +state_class+ on the stack.
13
+ def initialize( name, state_class, data=nil )
14
+ super( name )
15
+
16
+ @state_class = state_class
17
+ @data = data
18
+ end
19
+
20
+
21
+ ######
22
+ public
23
+ ######
24
+
25
+ ##
26
+ # The State to push to.
27
+ attr_reader :state_class
28
+
29
+ ##
30
+ # The data object to pass to the #state_class's constructor
31
+ attr_reader :data
32
+
33
+
34
+ ### Apply the transition to the given +stack+.
35
+ def apply( stack )
36
+ raise Pushdown::TransitionError, "can't switch on an empty stack" if stack.empty?
37
+
38
+ state = self.state_class.new( self.data )
39
+
40
+ self.log.debug "switching current state with a new state: %p" % [ state ]
41
+ old_state = stack.pop
42
+ old_state.on_stop if old_state
43
+
44
+ stack.push( state )
45
+ state.on_start
46
+
47
+ return stack
48
+ end
49
+
50
+ end # class Pushdown::Transition::Switch
@@ -0,0 +1,68 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require 'loggability'
5
+ require 'pluggability'
6
+
7
+ require 'pushdown' unless defined?( Pushdown )
8
+ require 'pushdown/state'
9
+
10
+
11
+ # A transition in a Pushdown automaton
12
+ class Pushdown::Transition
13
+ extend Loggability,
14
+ Pluggability
15
+
16
+ # Loggability API -- log to the pushdown logger
17
+ log_to :pushdown
18
+
19
+ # Pluggability API -- concrete types live in lib/pushdown/transition/
20
+ plugin_prefixes 'pushdown/transition'
21
+ plugin_exclusions 'spec/**/*'
22
+
23
+
24
+ # Don't allow direct instantiation (abstract class)
25
+ private_class_method :new
26
+
27
+
28
+ ### Inheritance hook -- enable instantiation.
29
+ def self::inherited( subclass )
30
+ super
31
+ subclass.public_class_method( :new )
32
+ if (( type_name = subclass.name&.sub( /.*::/, '' )&.downcase ))
33
+ Pushdown::State.register_transition( type_name )
34
+ end
35
+ end
36
+
37
+
38
+ ### Create a new Transition with the given +name+.
39
+ def initialize( name )
40
+ @name = name
41
+ end
42
+
43
+
44
+ ######
45
+ public
46
+ ######
47
+
48
+ ##
49
+ # The name of the transition; mostly for human consumption
50
+ attr_reader :name
51
+
52
+
53
+ ### Return a state +stack+ after the transition has been applied.
54
+ def apply( stack )
55
+ raise NotImplementedError, "%p doesn't implement required method #%p" %
56
+ [ self.class, __method__ ]
57
+ end
58
+
59
+
60
+ ### Return the transition's type as a lowercase Symbol, such as that specified
61
+ ### in transition declarations.
62
+ def type_name
63
+ class_name = self.class.name or return :anonymous
64
+ return class_name.sub( /.*::/, '' ).downcase.to_sym
65
+ end
66
+
67
+ end # class Pushdown::Transition
68
+
data/lib/pushdown.rb CHANGED
@@ -15,17 +15,18 @@ module Pushdown
15
15
  extend Loggability
16
16
 
17
17
  # Package version
18
- VERSION = '0.0.1'
18
+ VERSION = '0.4.0'
19
19
 
20
20
 
21
21
  # Loggability API -- create a logger for Pushdown classes and modules
22
22
  log_as :pushdown
23
23
 
24
+ end # module Pushdown
24
25
 
25
- autoload :Automaton, 'pushdown/automaton'
26
- autoload :State, 'pushdown/state'
27
- autoload :Transition, 'pushdown/transition'
28
-
26
+ require 'pushdown/transition'
27
+ require 'pushdown/state'
28
+ require 'pushdown/automaton'
29
29
 
30
- end # module Pushdown
30
+ Pushdown::Transition.plugin_exclusions( '**/spec/pushdown/transition/**' )
31
+ Pushdown::Transition.load_all
31
32
 
@@ -0,0 +1,152 @@
1
+ # -*- ruby -*-
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../spec_helper'
5
+
6
+ require 'pushdown/automaton'
7
+
8
+
9
+ RSpec.describe( Pushdown::Automaton ) do
10
+
11
+ let( :extended_class ) do
12
+ the_class = Class.new
13
+ the_class.extend( described_class )
14
+ the_class
15
+ end
16
+
17
+ let( :starting_state ) do
18
+ Class.new( Pushdown::State ) do
19
+ transition_push :run, :running
20
+ def on_event( event, * )
21
+ return :run if event == :run
22
+ return nil
23
+ end
24
+ end
25
+ end
26
+ let( :off_state ) do
27
+ Class.new( Pushdown::State ) do
28
+ transition_push :start, :starting
29
+ def update( * )
30
+ return :start
31
+ end
32
+ end
33
+ end
34
+ let( :running_state ) { Class.new(Pushdown::State) }
35
+
36
+ let( :state_class_registry ) {{
37
+ starting: starting_state,
38
+ off: off_state,
39
+ running: running_state,
40
+ }}
41
+
42
+
43
+ it "allows a state attribute to be declared" do
44
+ extended_class.pushdown_state( :state, initial_state: :idle )
45
+ extended_class.const_set( :Idle, starting_state )
46
+
47
+ instance = extended_class.new
48
+
49
+ expect( instance.state ).to be_a( starting_state )
50
+ end
51
+
52
+
53
+ it "allows a state class registry to be inferred" do
54
+ extended_class.pushdown_state( :state, initial_state: :starting )
55
+ extended_class.const_set( :Starting, starting_state )
56
+
57
+ expect( extended_class.initial_state ).to be( starting_state )
58
+ end
59
+
60
+
61
+ it "allows a state registry to be passed as a Hash-alike" do
62
+ extended_class.pushdown_state( :status, initial_state: :starting, states: state_class_registry )
63
+
64
+ expect( extended_class.initial_status ).to be( starting_state )
65
+ end
66
+
67
+
68
+ context "for each pushdown state" do
69
+
70
+ it "allows a state registry to be passed as a (pluggable) Pushdown::State subclass" do
71
+ state_baseclass = Class.new( Pushdown::State ) do
72
+ singleton_class.attr_accessor :subclasses
73
+ def self::get_subclass( classname )
74
+ return subclasses[ classname ]
75
+ end
76
+ def self::create( class_name, *args )
77
+ return self.get_subclass( class_name ).new( *args )
78
+ end
79
+ end
80
+
81
+ state_baseclass.subclasses = state_class_registry
82
+ extended_class.pushdown_state( :state, initial_state: :starting, states: state_baseclass )
83
+
84
+ expect( extended_class.initial_state ).to be( starting_state )
85
+ end
86
+
87
+
88
+ it "generates an event method that applies transitions returned from #on_event" do
89
+ extended_class.pushdown_state( :state, initial_state: :starting )
90
+ extended_class.const_set( :Starting, starting_state )
91
+ extended_class.const_set( :Off, off_state )
92
+ extended_class.const_set( :Running, running_state )
93
+
94
+ instance = extended_class.new
95
+ result = instance.handle_state_event( :run )
96
+
97
+ expect( result ).to be_a( Pushdown::Transition::Push )
98
+ expect( instance.state ).to be_a( running_state )
99
+ end
100
+
101
+
102
+ it "generates an periodic update method that applies transitions returned from #update on the current state" do
103
+ extended_class.pushdown_state( :status, initial_state: :off )
104
+ extended_class.const_set( :Starting, starting_state )
105
+ extended_class.const_set( :Off, off_state )
106
+ extended_class.const_set( :Running, running_state )
107
+
108
+ instance = extended_class.new
109
+ result = instance.update_status
110
+
111
+ expect( result ).to be_a( Pushdown::Transition::Push )
112
+ expect( instance.status ).to be_a( starting_state )
113
+ end
114
+
115
+
116
+ it "generates an periodic update method that is called on every state in the stack" do
117
+ extended_class.pushdown_state( :status, initial_state: :off )
118
+ extended_class.const_set( :Starting, starting_state )
119
+ extended_class.const_set( :Off, off_state )
120
+ extended_class.const_set( :Running, running_state )
121
+
122
+ instance = extended_class.new
123
+ result = instance.shadow_update_status
124
+
125
+ expect( result ).to be_nil
126
+ expect( instance.status ).to be_a( off_state )
127
+ end
128
+
129
+
130
+ it "fetches initial state data if the extended class defines a method for doing so" do
131
+ extended_class.pushdown_state( :state, initial_state: :starting )
132
+ extended_class.const_set( :Starting, starting_state )
133
+ extended_class.const_set( :Off, off_state )
134
+ extended_class.const_set( :Running, running_state )
135
+ extended_class.attr_accessor :state_data
136
+ extended_class.define_method( :initial_state_data ) do
137
+ return self.state_data ||= {}
138
+ end
139
+
140
+ starting_state.define_method( :on_start ) do
141
+ data[:starting_started] = true
142
+ end
143
+
144
+ instance = extended_class.new
145
+
146
+ expect( instance.state_data ).to eq({ starting_started: true })
147
+ end
148
+
149
+ end
150
+
151
+ end
152
+