statifier 0.0.1 → 0.1.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +601 -16
  5. data/lib/statifier/accepts.rb +167 -0
  6. data/lib/statifier/basichttp/decode.rb +142 -0
  7. data/lib/statifier/basichttp/encoding.rb +224 -0
  8. data/lib/statifier/basichttp/net_http_transport.rb +157 -0
  9. data/lib/statifier/basichttp/outbound.rb +87 -0
  10. data/lib/statifier/basichttp/processor.rb +170 -0
  11. data/lib/statifier/basichttp/request.rb +94 -0
  12. data/lib/statifier/basichttp/response.rb +34 -0
  13. data/lib/statifier/basichttp.rb +47 -0
  14. data/lib/statifier/compiler/content.rb +131 -0
  15. data/lib/statifier/compiler/error.rb +30 -0
  16. data/lib/statifier/compiler/expressions.rb +64 -0
  17. data/lib/statifier/compiler/numbering.rb +145 -0
  18. data/lib/statifier/compiler/parts.rb +144 -0
  19. data/lib/statifier/compiler/walk.rb +61 -0
  20. data/lib/statifier/compiler.rb +82 -0
  21. data/lib/statifier/datamodel/data_binding.rb +114 -0
  22. data/lib/statifier/datamodel/delay.rb +54 -0
  23. data/lib/statifier/datamodel/location.rb +145 -0
  24. data/lib/statifier/datamodel/program.rb +82 -0
  25. data/lib/statifier/datamodel/reason_value.rb +42 -0
  26. data/lib/statifier/datamodel/reasons.rb +88 -0
  27. data/lib/statifier/datamodel/stores.rb +169 -0
  28. data/lib/statifier/datamodel/system_variables.rb +69 -0
  29. data/lib/statifier/datamodel.rb +120 -0
  30. data/lib/statifier/document/data.rb +36 -0
  31. data/lib/statifier/document/executable.rb +69 -0
  32. data/lib/statifier/document/invoke.rb +17 -0
  33. data/lib/statifier/document/states.rb +40 -0
  34. data/lib/statifier/document.rb +51 -0
  35. data/lib/statifier/driver/calls.rb +183 -0
  36. data/lib/statifier/driver/checks.rb +110 -0
  37. data/lib/statifier/driver/delivery.rb +45 -0
  38. data/lib/statifier/driver/invocations.rb +129 -0
  39. data/lib/statifier/driver/mailbox.rb +120 -0
  40. data/lib/statifier/driver/routing.rb +193 -0
  41. data/lib/statifier/driver/session_id.rb +40 -0
  42. data/lib/statifier/driver/work.rb +196 -0
  43. data/lib/statifier/driver.rb +205 -0
  44. data/lib/statifier/effect.rb +208 -0
  45. data/lib/statifier/event.rb +67 -0
  46. data/lib/statifier/frozen.rb +22 -0
  47. data/lib/statifier/interpreter/blocks.rb +142 -0
  48. data/lib/statifier/interpreter/content.rb +228 -0
  49. data/lib/statifier/interpreter/entering.rb +141 -0
  50. data/lib/statifier/interpreter/exit_entry.rb +174 -0
  51. data/lib/statifier/interpreter/foreach.rb +83 -0
  52. data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
  53. data/lib/statifier/interpreter/invoke/passes.rb +143 -0
  54. data/lib/statifier/interpreter/invoke.rb +145 -0
  55. data/lib/statifier/interpreter/loop.rb +170 -0
  56. data/lib/statifier/interpreter/machine_state.rb +152 -0
  57. data/lib/statifier/interpreter/macrostep.rb +185 -0
  58. data/lib/statifier/interpreter/name_match.rb +43 -0
  59. data/lib/statifier/interpreter/selection.rb +221 -0
  60. data/lib/statifier/interpreter/send.rb +223 -0
  61. data/lib/statifier/interpreter/step.rb +138 -0
  62. data/lib/statifier/interpreter/stop.rb +66 -0
  63. data/lib/statifier/interpreter/targets.rb +107 -0
  64. data/lib/statifier/interpreter.rb +57 -0
  65. data/lib/statifier/lowering/branches.rb +84 -0
  66. data/lib/statifier/lowering/data.rb +102 -0
  67. data/lib/statifier/lowering/error.rb +102 -0
  68. data/lib/statifier/lowering/executable.rb +106 -0
  69. data/lib/statifier/lowering/placement.rb +65 -0
  70. data/lib/statifier/lowering/reading.rb +53 -0
  71. data/lib/statifier/lowering/states.rb +111 -0
  72. data/lib/statifier/lowering.rb +129 -0
  73. data/lib/statifier/machine/content.rb +81 -0
  74. data/lib/statifier/machine/data.rb +42 -0
  75. data/lib/statifier/machine/states.rb +56 -0
  76. data/lib/statifier/machine/values.rb +37 -0
  77. data/lib/statifier/machine.rb +103 -0
  78. data/lib/statifier/position/rebuild.rb +84 -0
  79. data/lib/statifier/position/shape.rb +73 -0
  80. data/lib/statifier/position/text.rb +43 -0
  81. data/lib/statifier/position.rb +206 -0
  82. data/lib/statifier/validator/attributes.rb +35 -0
  83. data/lib/statifier/validator/context.rb +74 -0
  84. data/lib/statifier/validator/data.rb +136 -0
  85. data/lib/statifier/validator/error.rb +90 -0
  86. data/lib/statifier/validator/executable.rb +139 -0
  87. data/lib/statifier/validator/history.rb +81 -0
  88. data/lib/statifier/validator/messages.rb +142 -0
  89. data/lib/statifier/validator/states.rb +161 -0
  90. data/lib/statifier/validator.rb +94 -0
  91. data/lib/statifier/version.rb +4 -1
  92. data/lib/statifier/xml/character_data.rb +142 -0
  93. data/lib/statifier/xml/cursor.rb +131 -0
  94. data/lib/statifier/xml/markup.rb +153 -0
  95. data/lib/statifier/xml/nodes.rb +50 -0
  96. data/lib/statifier/xml/parser.rb +121 -0
  97. data/lib/statifier/xml/scope.rb +46 -0
  98. data/lib/statifier/xml/tags.rb +112 -0
  99. data/lib/statifier/xml.rb +85 -0
  100. data/lib/statifier.rb +386 -2
  101. metadata +129 -20
  102. data/.gitignore +0 -8
  103. data/CODE_OF_CONDUCT.md +0 -74
  104. data/Gemfile +0 -11
  105. data/Gemfile.lock +0 -33
  106. data/Rakefile +0 -10
  107. data/bin/console +0 -8
  108. data/bin/setup +0 -6
  109. data/statifier.gemspec +0 -34
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # The event-name matcher of the specification's section 3.13 (Appendix
6
+ # D's `nameMatch`), as the reference matches.
7
+ #
8
+ # A descriptor matches an event name when its tokens are a prefix of the
9
+ # event's tokens on token boundaries: `error` matches `error.execution`
10
+ # and not `errors.execution`. A trailing `*` token, or the empty token a
11
+ # trailing dot leaves, is dropped before the prefix test, so `*` matches
12
+ # every event, `foo.*` matches `foo` and `foo.bar`, and `foo.` matches as
13
+ # `foo` does. A `*` anywhere else is matched as written.
14
+ #
15
+ # A transition's descriptors arrive split on their dots by the compiler
16
+ # (`Machine::Transition#events`), so both sides here are token lists; an
17
+ # event name is split once per selection round by {tokenize}.
18
+ #
19
+ # @api private
20
+ module NameMatch
21
+ module_function
22
+
23
+ # Whether any of `descriptors` (a list of token lists, one per
24
+ # descriptor of a transition's `event` attribute) matches the event
25
+ # whose name is `event_tokens`.
26
+ def name_match?(descriptors, event_tokens) = descriptors.any? { descriptor_match?(_1, event_tokens) }
27
+
28
+ # Whether one descriptor's tokens, normalized, are a prefix of
29
+ # `event_tokens`.
30
+ def descriptor_match?(descriptor, event_tokens)
31
+ normalized = normalize(descriptor)
32
+ event_tokens.take(normalized.size) == normalized
33
+ end
34
+
35
+ # The descriptor with a trailing `*` or empty token dropped.
36
+ def normalize(descriptor) = ["*", ""].include?(descriptor.last) ? descriptor[0...-1] : descriptor
37
+
38
+ # An event name split on its dots, every empty part kept: the empty
39
+ # name is one empty token, as the reference splits it.
40
+ def tokenize(name) = name.empty? ? [""] : name.split(".", -1)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # Transition selection, ported function for function from the
6
+ # pseudocode as the reference ports it; each function names the one it
7
+ # ports.
8
+ #
9
+ # The domain half ({find_lcca}, {get_effective_target_states},
10
+ # {get_transition_domain} and {compute_exit_set}) answers which states a
11
+ # transition leaves; {ExitEntry} uses it too. The selection half
12
+ # ({condition_match}, {select_transitions},
13
+ # {select_eventless_transitions} and {remove_conflicting_transitions})
14
+ # answers which transitions fire.
15
+ #
16
+ # The two selection walks evaluate conditions against one context built
17
+ # per round from the datamodel the caller passes and the snapshot's
18
+ # configuration. A condition that fails, or answers something other than
19
+ # true or false, leaves its transition unselected: the walks answer,
20
+ # beside the transitions, every written condition they evaluated and
21
+ # how it came out ({Selected}), in walk order, and the caller raises an
22
+ # `error.execution` for each that failed.
23
+ #
24
+ # @api private
25
+ module Selection
26
+ # What a selection round answered: `transitions`, the transitions that
27
+ # fire, in the order the round took them; and `evaluations`, an
28
+ # {Evaluation} per written condition the round evaluated, in walk
29
+ # order.
30
+ Selected = ::Data.define(:transitions, :evaluations)
31
+
32
+ # One written condition evaluated: the transition's `t_index`; its
33
+ # `outcome`, `:enabled`, `:disabled` or `:error`; and `reason`, the
34
+ # failure an `:error` carries, else nil. A transition with no
35
+ # condition is never recorded.
36
+ Evaluation = ::Data.define(:t_index, :outcome, :reason)
37
+
38
+ module_function
39
+
40
+ # `findLCCA`: the least common compound ancestor of `indexes`
41
+ # ({Machine#lcca}). A parallel is never one.
42
+ def find_lcca(machine, indexes) = machine.lcca(indexes)
43
+
44
+ # `getEffectiveTargetStates`: the transition's targets, each history
45
+ # target resolved to its recorded value when it has one, else to its
46
+ # default transition's own effective targets, as a frozen Array. The
47
+ # list is not deduplicated; no caller's answer changes on a repeat.
48
+ def get_effective_target_states(snapshot, transition)
49
+ transition.targets.flat_map { effective_target_states(snapshot, _1) }.freeze
50
+ end
51
+
52
+ # One target's effective states.
53
+ def effective_target_states(snapshot, target)
54
+ machine = snapshot.machine
55
+ return [target] unless machine.history?(target)
56
+ return snapshot.history.fetch(target) if snapshot.history.key?(target)
57
+
58
+ default = machine.transitions.fetch(machine.state(target).history_default)
59
+ get_effective_target_states(snapshot, default)
60
+ end
61
+
62
+ # `getTransitionDomain`: the state whose active descendants the
63
+ # transition exits, or nil when its effective targets are empty. An
64
+ # internal transition whose source is compound and whose every
65
+ # effective target is a proper descendant of it has the source as its
66
+ # domain; any other has the least common compound ancestor of its
67
+ # source and its effective targets.
68
+ def get_transition_domain(snapshot, transition)
69
+ targets = get_effective_target_states(snapshot, transition)
70
+ return nil if targets.empty?
71
+
72
+ machine = snapshot.machine
73
+ source = transition.source
74
+ return source if internal_domain?(machine, transition, targets)
75
+
76
+ find_lcca(machine, [source, *targets])
77
+ end
78
+
79
+ # Whether an internal transition's domain is its own source.
80
+ def internal_domain?(machine, transition, targets)
81
+ source = transition.source
82
+ transition.type == :internal && machine.compound?(source) &&
83
+ targets.all? { machine.descendant?(_1, source) }
84
+ end
85
+
86
+ # `computeExitSet`: every active state that is a proper descendant of
87
+ # some transition's domain, across `transitions`, as a frozen Array in
88
+ # document order (exit order is its reverse). A transition with no
89
+ # written target contributes nothing, and neither does one whose
90
+ # written targets resolve to no domain.
91
+ def compute_exit_set(snapshot, transitions)
92
+ exit_set = transitions.reject { _1.targets.empty? }.flat_map do |transition|
93
+ domain = get_transition_domain(snapshot, transition)
94
+ domain.nil? ? [] : snapshot.configuration.select { snapshot.machine.descendant?(_1, domain) }
95
+ end
96
+ exit_set.uniq.sort.freeze
97
+ end
98
+
99
+ # `conditionMatch`: the transition's condition evaluated against `data`
100
+ # and the snapshot's configuration (for `In()`); no condition is true.
101
+ #
102
+ # @return [Datamodel::Evaluated] true or false, or the reason it failed
103
+ def condition_match(snapshot, transition, data, clock: nil)
104
+ Datamodel.evaluate_cond(context(snapshot, data, clock), transition.cond)
105
+ end
106
+
107
+ # `selectTransitions`: the transitions the event named `name` enables,
108
+ # at most one per active atomic state, the states taken in document
109
+ # order. Each state is searched, then each proper ancestor outward, for
110
+ # the first transition in document order whose descriptors match the
111
+ # name and whose condition holds, so a descendant's transition preempts
112
+ # an ancestor's. The first occurrence of each transition is kept, and
113
+ # the list passes through {remove_conflicting_transitions}.
114
+ #
115
+ # @return [Selected]
116
+ def select_transitions(snapshot, name, data, clock: nil)
117
+ round(snapshot, context(snapshot, data, clock), NameMatch.tokenize(name))
118
+ end
119
+
120
+ # `selectEventlessTransitions`: {select_transitions}'s walk over the
121
+ # transitions that name no event. A transition naming one is never
122
+ # taken here, and an eventless one never by {select_transitions}.
123
+ #
124
+ # @return [Selected]
125
+ def select_eventless_transitions(snapshot, data, clock: nil)
126
+ round(snapshot, context(snapshot, data, clock), nil)
127
+ end
128
+
129
+ # `removeConflictingTransitions`: the enabled transitions, filtered to
130
+ # a set no two of which exit a common state. Taken in the order given,
131
+ # each is checked against those kept so far: on a conflict, one whose
132
+ # source is a descendant of the kept one's source preempts it and keeps
133
+ # checking; otherwise the later one is dropped and the check stops. A
134
+ # surviving transition is appended after every kept one it preempted is
135
+ # removed.
136
+ def remove_conflicting_transitions(snapshot, enabled)
137
+ enabled.reduce([]) do |filtered, candidate|
138
+ preempted = resolve(snapshot, candidate, filtered)
139
+ preempted.nil? ? filtered : (filtered - preempted) + [candidate]
140
+ end.freeze
141
+ end
142
+
143
+ # The kept transitions `candidate` preempts, or nil when a kept one
144
+ # preempts `candidate`.
145
+ def resolve(snapshot, candidate, filtered)
146
+ filtered.each_with_object([]) do |kept, preempted|
147
+ next unless conflict?(snapshot, candidate, kept)
148
+ return nil unless snapshot.machine.descendant?(candidate.source, kept.source)
149
+
150
+ preempted << kept
151
+ end
152
+ end
153
+
154
+ # Whether two transitions' exit sets share a state.
155
+ def conflict?(snapshot, one, other)
156
+ compute_exit_set(snapshot, [one]).intersect?(compute_exit_set(snapshot, [other]))
157
+ end
158
+
159
+ # The round both selection walks share: every active atomic state in
160
+ # document order, the written conditions recorded in walk order, the
161
+ # first occurrence of each transition kept and the conflicts removed.
162
+ # `tokens` is nil for the eventless walk.
163
+ def round(snapshot, context, tokens)
164
+ evaluations = []
165
+ enabled = snapshot.configuration.select { snapshot.machine.atomic?(_1) }.sort.filter_map do |atomic|
166
+ selected_for_atomic_state(snapshot.machine, context, atomic, tokens, evaluations)
167
+ end
168
+ transitions = remove_conflicting_transitions(snapshot, enabled.uniq(&:t_index))
169
+ Selected.new(transitions:, evaluations: evaluations.freeze)
170
+ end
171
+
172
+ # The labelled `break loop` of one atomic state's search: the state,
173
+ # then each proper ancestor outward, stopping at the first that has an
174
+ # enabled transition.
175
+ def selected_for_atomic_state(machine, context, atomic, tokens, evaluations)
176
+ [atomic, *machine.proper_ancestors(atomic)].each do |index|
177
+ found = first_matching_transition(machine, context, index, tokens, evaluations)
178
+ return found unless found.nil?
179
+ end
180
+ nil
181
+ end
182
+
183
+ # A state's own transitions in document order, stopping at the first
184
+ # enabled one, so a later sibling's condition is never evaluated once
185
+ # an earlier one is taken.
186
+ def first_matching_transition(machine, context, index, tokens, evaluations)
187
+ machine.state(index).transitions.each do |t_index|
188
+ transition = machine.transitions.fetch(t_index)
189
+ return transition if enabled?(context, transition, tokens, evaluations)
190
+ end
191
+ nil
192
+ end
193
+
194
+ # The eventless predicate when `tokens` is nil, the event-matched one
195
+ # otherwise; both end in the transition's condition, which, when
196
+ # written, is recorded in `evaluations`.
197
+ def enabled?(context, transition, tokens, evaluations)
198
+ eventless = transition.events.empty?
199
+ return false unless tokens.nil? ? eventless : !eventless && NameMatch.name_match?(transition.events, tokens)
200
+ return true if transition.cond.nil?
201
+
202
+ evaluated = Datamodel.evaluate_cond(context, transition.cond)
203
+ evaluations << evaluation(transition, evaluated)
204
+ evaluated.ok? && evaluated.value
205
+ end
206
+
207
+ # How one written condition came out.
208
+ def evaluation(transition, evaluated)
209
+ t_index = transition.t_index
210
+ return Evaluation.new(t_index:, outcome: :error, reason: evaluated.error) unless evaluated.ok?
211
+
212
+ Evaluation.new(t_index:, outcome: evaluated.value ? :enabled : :disabled, reason: nil)
213
+ end
214
+
215
+ # The context one round evaluates against.
216
+ def context(snapshot, data, clock)
217
+ Datamodel.context(data, snapshot.machine, snapshot.configuration, clock:)
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # `<send>` and `<cancel>`, as the reference's send and cancel nodes run
6
+ # them (`Statifier.Machine.Content.Send` and `.Cancel` in statifier-ex),
7
+ # with the event data its `Statifier.EventData` makes.
8
+ #
9
+ # The core delivers nothing and schedules nothing. A `<send>` answers
10
+ # one effect describing the message, {Effect::Send} when it goes now and
11
+ # {Effect::SendDelayed} with its delay when it waits, whatever its
12
+ # target, `#_internal` included; the driver delivers it. A `<cancel>`
13
+ # answers an {Effect::Cancel}.
14
+ #
15
+ # A `<send>` resolves its arguments in the reference's order: the event,
16
+ # the target, the type, the delay, the `namelist` and `<param>` values in
17
+ # document order, then `<content>`. The first that fails discards the
18
+ # send: no id is minted, nothing is written, and the failure is the
19
+ # reason the block's `error.execution` carries. Then the send id is
20
+ # minted (or the author's `id` read) and `idlocation` written; a write
21
+ # that fails discards the send the same way. Only then are the type and
22
+ # the target judged ({Targets.reject_reason}): a refused send keeps its
23
+ # minted id and its `idlocation` write, answers no effect, and fails with
24
+ # a {SendRejected} naming the id and the event it raises.
25
+ #
26
+ # @api private
27
+ module Send
28
+ # What a session carries for its sends from block to block, never a
29
+ # module variable: `send_counter`, the sequence a generated id is
30
+ # minted from (the next id is `send_` and the counter plus one);
31
+ # `timer_counter`, the ordinal sequence every delayed send, every
32
+ # cancel and every immediate send of a registered type advances;
33
+ # `send_types`, the types the host registered a processor for, or nil
34
+ # for none; and `routes`, the {Targets::Routes} the driver last
35
+ # declared, or nil when it declared none.
36
+ SendState = ::Data.define(:send_counter, :timer_counter, :send_types, :routes) do
37
+ # The state with the next generated id minted.
38
+ def mint = with(send_counter: send_counter + 1)
39
+
40
+ # The state with the timer counter advanced.
41
+ def tick = with(timer_counter: timer_counter + 1)
42
+ end
43
+
44
+ class SendState
45
+ # The send state before the first send.
46
+ INITIAL = new(send_counter: 0, timer_counter: 0, send_types: nil, routes: nil)
47
+ end
48
+
49
+ # A `<send>` refused after its id was minted: `send_id`; `kind`,
50
+ # `:execution` or `:communication`, the `error.*` event it raises; and
51
+ # `reason`, the refusal the event's data carries.
52
+ SendRejected = ::Data.define(:send_id, :kind, :reason)
53
+
54
+ # What executing a `<send>` or `<cancel>` answered: the `context` and
55
+ # `sends` the block keeps, the `effects` in order, and `error`, the
56
+ # reason it failed, nil when it did not. A failure answers no effect,
57
+ # and an argument failure keeps the context and send state it started
58
+ # with.
59
+ Sent = ::Data.define(:context, :sends, :effects, :error) do
60
+ # @return [Boolean] whether the element succeeded
61
+ def ok? = error.nil?
62
+ end
63
+
64
+ # The whitespace a text payload is normalized over, the ASCII set.
65
+ RUNS_OF_SPACE = /[ \t\n\v\f\r]+/
66
+
67
+ # A successful evaluation of nil, for an attribute not written.
68
+ NONE = Datamodel::Evaluated.new(value: nil, error: nil)
69
+
70
+ module_function
71
+
72
+ # Executes the {Machine::Send} `node` against `context` and `sends`;
73
+ # `stamp` ({Content::Stamp}) names the block and the counters.
74
+ #
75
+ # @return [Sent]
76
+ def execute_send(context, node, sends, stamp)
77
+ resolved = resolve_arguments(context, node)
78
+ return failed(context, sends, resolved) unless resolved.is_a?(Hash)
79
+
80
+ send_id, minted = mint(node, sends)
81
+ write = node.idlocation && Datamodel::Location.write(context, node.idlocation, send_id)
82
+ return failed(context, sends, write.error) if write && !write.ok?
83
+
84
+ judged(node, resolved.merge(send_id:, write:), minted, stamp, write ? write.context : context)
85
+ end
86
+
87
+ # The send id and the send state after it: the author's `id`, which
88
+ # consumes no counter, else the next generated id.
89
+ def mint(node, sends) = node.id.nil? ? ["send_#{sends.mint.send_counter}", sends.mint] : [node.id, sends]
90
+
91
+ # A send whose id is minted and whose `idlocation` is written, judged
92
+ # ({Targets.reject_reason}): refused, keeping both, or accepted.
93
+ def judged(node, resolved, sends, stamp, context)
94
+ kind, reason = Targets.reject_reason(*resolved.values_at(:target, :type, :delay_ms), sends.send_types,
95
+ sends.routes)
96
+ return failed(context, sends, SendRejected.new(send_id: resolved[:send_id], kind:, reason:)) if kind
97
+
98
+ accepted(node, resolved, sends, stamp, context)
99
+ end
100
+
101
+ # Executes the {Machine::Cancel} `node`: its send id resolved and the
102
+ # effect naming it. An id that names nothing pending is the host's
103
+ # no-op, not a failure.
104
+ #
105
+ # @return [Sent]
106
+ def execute_cancel(context, node, sends, stamp)
107
+ evaluated = Datamodel.evaluate(context, node.sendid)
108
+ return failed(context, sends, evaluated.error) unless evaluated.ok?
109
+
110
+ sends = sends.tick
111
+ effect = Effect::Cancel.new(send_id: evaluated.value, c_index: node.c_index, **stamp.fields,
112
+ ordinal: sends.timer_counter, caller_context: stamp.caller_context)
113
+ Sent.new(context:, sends:, effects: [effect].freeze, error: nil)
114
+ end
115
+
116
+ # A `<content>` body as data, as the reference's event data reads one:
117
+ # blank text is no data; text that predicator reads as a literal (a
118
+ # number, a string, a list, a map) is that value; anything else is the
119
+ # trimmed text with each run of whitespace one space.
120
+ def text_data(text)
121
+ trimmed = text.nil? ? "" : Lowering::Errors.trim(text)
122
+ return Predicator::UNDEFINED if trimmed.empty?
123
+
124
+ compiled = Predicator.compile(trimmed)
125
+ evaluated = compiled.ok? ? Predicator.evaluate(compiled.value, {}, on_unbound: :error) : compiled
126
+ evaluated.ok? ? evaluated.value : trimmed.gsub(RUNS_OF_SPACE, " ")
127
+ end
128
+
129
+ # `namelist` and `<param>` values (name and value pairs) as data: a
130
+ # Hash, the last of a repeated name winning, or no data when there are
131
+ # none.
132
+ def params_data(pairs) = pairs.empty? ? Predicator::UNDEFINED : pairs.to_h.freeze
133
+
134
+ # Every argument resolved, as a Hash, or the reason the first failed.
135
+ def resolve_arguments(context, node)
136
+ resolved = {}
137
+ { event: node.event, target: node.target, type: node.type }.each do |key, expr|
138
+ evaluated = expr.nil? ? NONE : Datamodel.evaluate(context, expr)
139
+ return evaluated.error unless evaluated.ok?
140
+
141
+ resolved[key] = evaluated.value
142
+ end
143
+ resolve_rest(context, node, resolved)
144
+ end
145
+
146
+ # The delay and the payload, after the first three.
147
+ def resolve_rest(context, node, resolved)
148
+ delay = resolve_delay(context, node.delay)
149
+ return delay.error unless delay.ok?
150
+
151
+ data = resolve_data(context, node)
152
+ data.ok? ? resolved.merge(delay_ms: delay.value, data: data.value) : data.error
153
+ end
154
+
155
+ # The delay in whole milliseconds ({Datamodel::Delay}), nil for a send
156
+ # with none.
157
+ def resolve_delay(context, delay)
158
+ return NONE if delay.nil?
159
+
160
+ evaluated = Datamodel.evaluate(context, delay)
161
+ evaluated.ok? ? Datamodel::Delay.milliseconds(evaluated.value) : evaluated
162
+ end
163
+
164
+ # The payload: the `namelist` and `<param>` values, stopping at the
165
+ # first that fails, then, when the node has a `<content>` child, that
166
+ # child's data in their place.
167
+ def resolve_data(context, node)
168
+ pairs = resolve_params(context, node.namelist + node.params)
169
+ return pairs unless pairs.ok?
170
+
171
+ content = node.content
172
+ return value(params_data(pairs.value)) if content.nil?
173
+
174
+ content.is_a?(Machine::Static) ? value(text_data(content.value)) : Datamodel.evaluate(context, content)
175
+ end
176
+
177
+ # A successful evaluation of `value`.
178
+ def value(value) = Datamodel::Evaluated.new(value:, error: nil)
179
+
180
+ # Each param's name and value, in order, or the first failure.
181
+ def resolve_params(context, params)
182
+ pairs = params.map do |param|
183
+ evaluated = Datamodel.evaluate(context, param.expr)
184
+ return evaluated unless evaluated.ok?
185
+
186
+ [param.name, evaluated.value]
187
+ end
188
+ value(pairs)
189
+ end
190
+
191
+ # A failure: no effect, and the context and send state the block keeps.
192
+ def failed(context, sends, error) = Sent.new(context:, sends:, effects: [].freeze, error:)
193
+
194
+ # A send that is not refused: the timer counter advanced for a delay
195
+ # or a registered type, its `idlocation` write's change, then its own
196
+ # effect. `resolved` carries the send id and the {Datamodel::Written}
197
+ # of its `idlocation`, nil when it has none.
198
+ def accepted(node, resolved, sends, stamp, context)
199
+ stamps = stamps?(resolved, sends)
200
+ sends = sends.tick if stamps
201
+ write = resolved[:write]
202
+ changes = write ? [Content.change(write, node.idlocation, resolved[:send_id], node.c_index, stamp)] : []
203
+ effect = send_effect(node, resolved, stamp, (sends.timer_counter if stamps))
204
+ Sent.new(context:, sends:, effects: (changes << effect).freeze, error: nil)
205
+ end
206
+
207
+ # Whether the send stamps an ordinal: a delay, or a registered type.
208
+ def stamps?(resolved, sends)
209
+ !resolved[:delay_ms].nil? || Targets.classify(sends.send_types, resolved[:type]) == :registered
210
+ end
211
+
212
+ # The effect itself, immediate or delayed.
213
+ def send_effect(node, resolved, stamp, ordinal)
214
+ fields = resolved.slice(:event, :target, :type, :data, :send_id)
215
+ .merge(c_index: node.c_index, **stamp.fields, ordinal:,
216
+ id_from_author?: !node.id.nil? || !node.idlocation.nil?)
217
+ return Effect::Send.new(**fields) if resolved[:delay_ms].nil?
218
+
219
+ Effect::SendDelayed.new(**fields, delay_ms: resolved[:delay_ms], caller_context: stamp.caller_context)
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # The specification's `microstep`, `exitStates` and `enterStates` with
6
+ # their content run, as the reference runs them
7
+ # (`Statifier.Interpreter.microstep/2` and
8
+ # `Statifier.Interpreter.ExitEntry` in statifier-ex). Which states leave
9
+ # and enter, in which order, is {ExitEntry}'s; this module walks those
10
+ # moves state by state against a {MachineState}: each exiting state runs
11
+ # its `<onexit>` blocks while it is still active and then leaves, and
12
+ # each entering state joins the configuration, binds its own `<data>`
13
+ # on its first entry under late binding, runs its `<onentry>` blocks,
14
+ # its initial and default-history content, and raises its completion
15
+ # events, its parent's carrying the final state's `<donedata>`. The
16
+ # blocks run through {Blocks}.
17
+ #
18
+ # @api private
19
+ module Step
20
+ module_function
21
+
22
+ # `microstep`: the states the transitions leave exit, each
23
+ # transition's content runs in the order given, and the states they
24
+ # reach enter.
25
+ #
26
+ # @return [Stepped]
27
+ def microstep(state, transitions)
28
+ exited = exit_states(state, transitions)
29
+ executed = execute_transition_content(exited.state, transitions)
30
+ exited.then_step(executed).then_step(enter_states(executed.state, transitions))
31
+ end
32
+
33
+ # `exitStates`: the exit set leaves the states to invoke, the history
34
+ # is recorded from the configuration before anything leaves, and each
35
+ # state in exit order runs its `<onexit>` blocks and leaves. The
36
+ # {Effect::Trace::ExitSet} comes first, stamped before the exits.
37
+ #
38
+ # @return [Stepped]
39
+ def exit_states(state, transitions)
40
+ moves = ExitEntry.exit_states(state.snapshot, transitions)
41
+ state = state.with(states_to_invoke: (state.states_to_invoke - moves.states).freeze)
42
+ trace = Blocks.traced(state, Effect::Trace::ExitSet, indexes: moves.states, configuration: moves.configuration)
43
+ departures(Stepped.new(state: state.with(history: moves.history), effects: trace), moves.states)
44
+ end
45
+
46
+ # Each of `indexes` leaving in turn, after `stepped`.
47
+ #
48
+ # @return [Stepped]
49
+ def departures(stepped, indexes)
50
+ indexes.reduce(stepped) { |acc, index| acc.then_step(depart(acc.state, index)) }
51
+ end
52
+
53
+ # One state leaving: its `<onexit>` blocks, the cancel of its live
54
+ # invocations ({Invoke::Passes.cancel_invocations}), then out of the
55
+ # configuration.
56
+ #
57
+ # @return [Stepped]
58
+ def depart(state, index)
59
+ left = Blocks.run_blocks(state, :onexit, index)
60
+ left = left.then_step(Invoke::Passes.cancel_invocations(left.state, index))
61
+ left.with(state: left.state.with(configuration: (left.state.configuration - [index]).freeze))
62
+ end
63
+
64
+ # `executeTransitionContent`: each transition's content, in the order
65
+ # given.
66
+ #
67
+ # @return [Stepped]
68
+ def execute_transition_content(state, transitions)
69
+ transitions.reduce(Stepped.new(state:, effects: [])) do |acc, transition|
70
+ acc.then_step(Blocks.run_transition_content(acc.state, transition.t_index))
71
+ end
72
+ end
73
+
74
+ # `enterStates`: each state of the entry set in entry order, with the
75
+ # {Effect::Trace::EntrySet} first, stamped before any entry.
76
+ #
77
+ # @return [Stepped]
78
+ def enter_states(state, transitions)
79
+ moves = ExitEntry.enter_states(state.snapshot, transitions)
80
+ trace = Blocks.traced(state, Effect::Trace::EntrySet, indexes: moves.states.map(&:index).freeze,
81
+ configuration: moves.configuration)
82
+ moves.states.reduce(Stepped.new(state:, effects: trace)) do |acc, arrival|
83
+ acc.then_step(arrive(acc.state, arrival))
84
+ end
85
+ end
86
+
87
+ # One {ExitEntry::Arrival}: the state joins the configuration and the
88
+ # states to invoke, binds its own `<data>` on its first entry, runs
89
+ # its `<onentry>` blocks, its initial content and its default-history
90
+ # content, and then raises its completions; a top-level final clears
91
+ # `running` instead.
92
+ #
93
+ # @return [Stepped]
94
+ def arrive(state, arrival)
95
+ index = arrival.index
96
+ stepped = enter(state, index)
97
+ stepped = stepped.then_step(Blocks.run_blocks(stepped.state, :onentry, index))
98
+ stepped = [arrival.initial_content, arrival.history_content].reduce(stepped) do |acc, t_index|
99
+ acc.then_step(Blocks.run_transition_content(acc.state, t_index))
100
+ end
101
+ stepped.with(state: complete(stepped.state, arrival))
102
+ end
103
+
104
+ # The state with `index` active, among the states to invoke and
105
+ # recorded as entered.
106
+ def join(state, index)
107
+ state.with(configuration: (state.configuration | [index]).sort.freeze,
108
+ states_to_invoke: (state.states_to_invoke | [index]).sort.freeze,
109
+ entered_states: (state.entered_states | [index]).sort.freeze)
110
+ end
111
+
112
+ # The state at `index` joined ({join}), with its own `<data>` bound
113
+ # when this is its first entry ({Datamodel::DataBinding.enter_state}).
114
+ #
115
+ # @return [Stepped]
116
+ def enter(state, index)
117
+ joined = join(state, index)
118
+ return Stepped.new(state: joined, effects: []) if state.entered_states.include?(index)
119
+
120
+ Blocks.bound(joined, Datamodel::DataBinding.enter_state(joined.context, index))
121
+ end
122
+
123
+ # The completions an arrival raises (origin `{kind: :state,
124
+ # state_index:}`, the final state's), or `running` cleared.
125
+ def complete(state, arrival)
126
+ return state.with(running: false) if arrival.stops
127
+
128
+ arrival.completions.reduce(state) do |acc, completion|
129
+ origin = { kind: :state, state_index: completion.final }
130
+ next acc.raise_platform(completion.name, origin) unless completion.donedata
131
+
132
+ acc, data = Blocks.donedata(acc, completion.final)
133
+ acc.raise_platform(completion.name, origin, data:)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end