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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Position
5
+ # A driver state rebuilt from a checked position, as the reference's
6
+ # `import/2` rebuilds its machine state: every id resolved to its index,
7
+ # the root re-added, the internal queue empty, and the fields a driver
8
+ # declares (the send types and the routes) stamped afresh.
9
+ #
10
+ # @api private
11
+ module Rebuild
12
+ module_function
13
+
14
+ # The driver state over `machine` the position holds, `datamodel` its
15
+ # decoded variables and `types` the registered send types.
16
+ #
17
+ # @return [Driver::State]
18
+ def state(machine, position, datamodel, types)
19
+ session_id = datamodel[SESSION_ID]
20
+ machine_state = machine_state(machine, position, datamodel, types, session_id)
21
+ DRIVER_STATE.new(machine_state:, session_id:, now_ms: 0, timers: [].freeze, timer_sequence: 0,
22
+ external_queue: [].freeze, held_sends: {}.freeze, halted: nil, done: done(machine_state),
23
+ send_types: types, invocations: {}.freeze, mailbox: [].freeze, invoked_as: nil,
24
+ cancelled: false)
25
+ end
26
+
27
+ # The interpreter's position. The root is active whenever any state
28
+ # is, and has been entered whenever any state has.
29
+ def machine_state(machine, position, datamodel, types, session_id)
30
+ configuration = indexes(machine, position["configuration"])
31
+ Interpreter::MachineState.new(
32
+ machine:, configuration: rooted(configuration), history: history(machine, position["history_values"]),
33
+ datamodel:, internal_queue: [].freeze, entered_states: rooted(indexes(machine, position["entered_states"])),
34
+ states_to_invoke: indexes(machine, position["states_to_invoke"]), sends: sends(position, types, session_id),
35
+ caller_context: nil, clock: nil, last_selection: nil, **fields(position),
36
+ active_invocations: invocations(machine, position["active_invocations"])
37
+ )
38
+ end
39
+
40
+ # The indexes of the states `ids` name, ascending, frozen.
41
+ def indexes(machine, ids) = ids.map { machine.id_to_index.fetch(_1) }.uniq.sort.freeze
42
+
43
+ # `indexes` with the root, when they hold any state.
44
+ def rooted(indexes) = indexes.empty? ? indexes : ([0] | indexes).sort.freeze
45
+
46
+ # The recorded history by index.
47
+ def history(machine, values)
48
+ values.to_h { |key, ids| [machine.id_to_index.fetch(key), indexes(machine, ids)] }.freeze
49
+ end
50
+
51
+ # The live invocations by state index and invoke index.
52
+ def invocations(machine, entries)
53
+ entries.to_h { [[machine.id_to_index.fetch(_1["state"]), _1["invoke_index"]].freeze, -_1["invoke_id"]] }.freeze
54
+ end
55
+
56
+ # The send state: the position's two sequences, and the types and
57
+ # routes a driver declares as a chart starts.
58
+ def sends(position, types, session_id)
59
+ Interpreter::Send::SendState.new(
60
+ send_counter: position["send_counter"], timer_counter: position["timer_counter"],
61
+ send_types: (types unless types.empty?), routes: DRIVER_CALLS.own_routes(session_id)
62
+ )
63
+ end
64
+
65
+ # The scalar fields, as the machine state holds them.
66
+ def fields(position)
67
+ budget = position["max_macrostep_rounds"]
68
+ { running: position["running"], status: position["status"].to_sym, macrostep: position["macrostep"],
69
+ microstep: position["microstep"], round: position["round"], trace: position["trace"],
70
+ max_macrostep_rounds: budget == "infinity" ? :infinity : budget, invoke_counter: position["invoke_counter"] }
71
+ end
72
+
73
+ # The driver's record of a stopped chart: a stopped position carries
74
+ # no donedata (the reference's carries it only in the done effect), so
75
+ # it is undefined, and the configuration is the position's own.
76
+ def done(machine_state)
77
+ return nil unless machine_state.status == :done
78
+
79
+ Effect::Done.new(donedata: Predicator::UNDEFINED, donedata_error: nil,
80
+ configuration: machine_state.configuration, **machine_state.counters)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Position
5
+ # The checks an import makes before it reads a state id, as the
6
+ # reference's import makes them (`check_required_keys/1` and
7
+ # `check_shapes/1` in statifier-ex): every key present, then each
8
+ # field's shape, the first that fails named; then every id resolved.
9
+ #
10
+ # @api private
11
+ module Shape
12
+ # A list of state ids.
13
+ ID_LIST = ->(value) { value.is_a?(Array) && value.all?(String) }
14
+
15
+ # One live invocation.
16
+ INVOCATION = lambda do |value|
17
+ value.is_a?(Hash) && value["state"].is_a?(String) && value["invoke_index"].is_a?(Integer) &&
18
+ value["invoke_id"].is_a?(String)
19
+ end
20
+
21
+ # A boolean.
22
+ BOOLEAN = ->(value) { [true, false].include?(value) }
23
+
24
+ # An integer.
25
+ INTEGER = ->(value) { value.is_a?(Integer) }
26
+
27
+ # Each history state's id to the ids it recorded.
28
+ HISTORY = ->(value) { value.is_a?(Hash) && value.all? { |key, ids| key.is_a?(String) && ID_LIST.call(ids) } }
29
+
30
+ # Each variable's name to its value's text.
31
+ TEXTS = ->(value) { value.is_a?(Hash) && value.all? { |key, text| key.is_a?(String) && text.is_a?(String) } }
32
+
33
+ # Each field's shape, in the order the fields are checked.
34
+ CHECKS = {
35
+ "configuration" => ID_LIST, "entered_states" => ID_LIST, "states_to_invoke" => ID_LIST,
36
+ "history_values" => HISTORY,
37
+ "active_invocations" => ->(value) { value.is_a?(Array) && value.all?(&INVOCATION) },
38
+ "invoke_counter" => INTEGER, "send_counter" => INTEGER, "timer_counter" => INTEGER,
39
+ "datamodel" => TEXTS,
40
+ "running" => BOOLEAN, "status" => ->(value) { %w[running done].include?(value) },
41
+ "macrostep" => INTEGER, "microstep" => INTEGER, "round" => INTEGER, "trace" => BOOLEAN,
42
+ "max_macrostep_rounds" => ->(value) { value == "infinity" || (value.is_a?(Integer) && value.positive?) }
43
+ }.freeze
44
+
45
+ module_function
46
+
47
+ # What makes `position` malformed, a detail Hash with its `kind`, or
48
+ # nil when it has the shape: not a Hash; required keys missing, every
49
+ # one named, sorted; or the first field of the wrong shape. A key that
50
+ # is not required is not looked at.
51
+ def failure(position)
52
+ return { kind: :not_a_hash } unless position.is_a?(Hash)
53
+
54
+ missing = KEYS.reject { position.key?(_1) }.sort
55
+ return { kind: :missing_keys, keys: missing } unless missing.empty?
56
+
57
+ field = CHECKS.find { |key, check| !check.call(position[key]) }&.first
58
+ { kind: :bad_shape, field: } if field
59
+ end
60
+
61
+ # Every id `position` names that `machine` holds no state for, once
62
+ # each, sorted.
63
+ def unknown_ids(machine, position) = named(position).reject { machine.id_to_index.key?(_1) }.uniq.sort
64
+
65
+ # Every state id a position's fields hold.
66
+ def named(position)
67
+ history = position["history_values"].flat_map { |key, ids| [key, *ids] }
68
+ position["configuration"] + position["entered_states"] + position["states_to_invoke"] + history +
69
+ position["active_invocations"].map { _1["state"] }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Position
5
+ # A position's datamodel as text: each variable's value in predicator's
6
+ # tagged-value encoding (`Predicator::Tagged`), and back.
7
+ #
8
+ # @api private
9
+ module Text
10
+ module_function
11
+
12
+ # The datamodel's variables, sorted by name, each value encoded:
13
+ # `[texts, nil]`, or `[nil, failure]` naming the first variable, in
14
+ # that order, with no encoding and the codec's reason.
15
+ def encode(datamodel)
16
+ texts = {}
17
+ datamodel.keys.sort.each do |root|
18
+ encoded = Predicator::Tagged.encode(datamodel[root])
19
+ return [nil, { field: "datamodel.#{root}", reason: encoded.error.reason }] unless encoded.ok?
20
+
21
+ texts[root] = encoded.value
22
+ end
23
+ [texts, nil]
24
+ end
25
+
26
+ # The variables decoded, a deeply frozen Hash of new values: `[data,
27
+ # nil]`, or `[nil, failure]` for the first text, by name, that does not
28
+ # decode, or for a `_sessionid` that is not a String.
29
+ def decode(texts)
30
+ data = {}
31
+ texts.keys.sort.each do |root|
32
+ decoded = Predicator::Tagged.decode(texts[root])
33
+ return [nil, { kind: :undecodable_value, field: "datamodel.#{root}" }] unless decoded.ok?
34
+
35
+ data[-root] = decoded.value
36
+ end
37
+ return [nil, { kind: :no_session_id, field: "datamodel.#{SESSION_ID}" }] unless data[SESSION_ID].is_a?(String)
38
+
39
+ [Ractor.make_shareable(data), nil]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "predicator/tagged"
4
+
5
+ module Statifier
6
+ # Position export and import: a chart's execution between two calls, in
7
+ # the string-id vocabulary the reference moves a position in and out with
8
+ # (`Statifier.Position.export/1` and `import/2` in statifier-ex), written
9
+ # as plain data a host can keep as JSON. {Statifier.export_position} and
10
+ # {Statifier.import_position} answer through this module.
11
+ #
12
+ # A position is a Hash with String keys, the reference's required export
13
+ # keys in its order ({KEYS}), every value a String, an Integer, a
14
+ # boolean, an Array or a Hash of those:
15
+ #
16
+ # - `configuration`, `entered_states` and `states_to_invoke`: state ids,
17
+ # sorted, the `<scxml>` root never named;
18
+ # - `history_values`: each history state's id to the ids it recorded,
19
+ # sorted;
20
+ # - `active_invocations`: one Hash per live invocation, `state` (the id of
21
+ # the state that owns the `<invoke>`), `invoke_index` (its ordinal
22
+ # among that state's own) and `invoke_id`, sorted by state and index;
23
+ # - `invoke_counter`, `send_counter` and `timer_counter`: the sequences
24
+ # behind the generated invoke ids, the generated send ids and the timer
25
+ # ordinals;
26
+ # - `datamodel`: every variable, the system variables included, by name,
27
+ # each value as text in predicator's tagged-value encoding
28
+ # (`Predicator::Tagged`), which carries the dates, durations and the
29
+ # undefined value JSON has no form for;
30
+ # - `running` and `trace`, booleans; `status`, `"running"` or `"done"`;
31
+ # the counters `macrostep`, `microstep` and `round`; and
32
+ # `max_macrostep_rounds`, a positive Integer or `"infinity"`.
33
+ #
34
+ # The reference's export carries the same keys as Elixir terms (sets,
35
+ # tuple keys, atoms) and an `identity`, the chart's content hash, name and
36
+ # version, which its import never reads. A machine here carries no
37
+ # identity, so a position carries none either, and an import loads a
38
+ # position onto any machine that holds every state it names, as the
39
+ # reference's does.
40
+ #
41
+ # Nothing of the driver's own is in a position, as the reference's
42
+ # position holds none of its session's: the virtual clock, the pending
43
+ # timers, the external queue, the sends a processor holds, a budget halt,
44
+ # the stopped chart's donedata, the state's last event selection, and the
45
+ # child sessions of its live invocations, their mailbox included. A
46
+ # position taken with a live child carries the invocation in
47
+ # `active_invocations`, as the core holds it, and nothing of the child, as
48
+ # the reference's session rebuilds its invocation table empty on a resume.
49
+ # An import starts them as a fresh start does: the clock at zero, no timer,
50
+ # nothing queued or held, no halt, no child; a send to such an
51
+ # invocation's id fails with `error.communication`, and no
52
+ # `done.invoke.<id>` arrives for it. The session id is the one the
53
+ # position's `_sessionid` holds, and the registered send types are the
54
+ # processors the host passes the import.
55
+ #
56
+ # A host reads {KEYS} and {Refusal} here; the bodies of the two calls
57
+ # ({Codec}) and the modules beneath them are private constants.
58
+ module Position
59
+ # The keys a position carries, in the reference's order, every one
60
+ # required by an import.
61
+ KEYS = %w[
62
+ configuration entered_states states_to_invoke history_values active_invocations invoke_counter
63
+ send_counter timer_counter datamodel running status macrostep microstep round trace max_macrostep_rounds
64
+ ].freeze
65
+
66
+ # The system variable the session id is read from.
67
+ SESSION_ID = "_sessionid"
68
+
69
+ # Why an export or an import was refused, the `error` of its
70
+ # {Statifier::Result}.
71
+ #
72
+ # @!attribute [r] reason
73
+ # @return [Symbol] `:internal_queue_not_empty`, `:unnameable_states`
74
+ # or `:unencodable_value` for an export; `:malformed_export` or
75
+ # `:unknown_state_ids` for an import
76
+ # @!attribute [r] detail
77
+ # @return [Hash] what the reason names, frozen: `indexes` for
78
+ # `:unnameable_states`; `field` and the codec's `reason` for
79
+ # `:unencodable_value`; `ids` for `:unknown_state_ids`; for
80
+ # `:malformed_export`, a `kind` (`:not_a_hash`, `:missing_keys` with
81
+ # `keys`, `:bad_shape`, `:undecodable_value` or `:no_session_id`,
82
+ # each with `field`); empty for `:internal_queue_not_empty`
83
+ Refusal = ::Data.define(:reason, :detail)
84
+
85
+ # The bodies of the two calls. Internal, with the modules beneath.
86
+ module Codec
87
+ module_function
88
+
89
+ # {Statifier.export_position}'s body.
90
+ #
91
+ # @return [Result]
92
+ def export(state)
93
+ machine_state = state.machine_state
94
+ return refused(:internal_queue_not_empty) unless machine_state.internal_queue.empty?
95
+
96
+ unnameable = unnameable(machine_state)
97
+ return refused(:unnameable_states, indexes: unnameable) unless unnameable.empty?
98
+
99
+ datamodel, failure = Text.encode(machine_state.datamodel)
100
+ return refused(:unencodable_value, **failure) if datamodel.nil?
101
+
102
+ ok(exported(machine_state, datamodel))
103
+ end
104
+
105
+ # The indexes, sorted, other than the root's, that the state holds and
106
+ # that no id names: a state the document gave no id.
107
+ def unnameable(machine_state)
108
+ machine = machine_state.machine
109
+ referenced(machine_state).reject { _1.zero? || nameable?(machine, _1) }.sort
110
+ end
111
+
112
+ # Every state index a position's fields hold, once each.
113
+ def referenced(machine_state)
114
+ history = machine_state.history.flat_map { |key, recorded| [key, *recorded] }
115
+ invoking = machine_state.active_invocations.keys.map(&:first)
116
+ (machine_state.configuration + machine_state.entered_states + machine_state.states_to_invoke + history +
117
+ invoking).uniq
118
+ end
119
+
120
+ # Whether the document's id for the state at `index` names it.
121
+ def nameable?(machine, index)
122
+ id = machine.state(index).id
123
+ !id.nil? && machine.id_to_index[id] == index
124
+ end
125
+
126
+ # The position, deeply frozen.
127
+ def exported(machine_state, datamodel)
128
+ machine = machine_state.machine
129
+ Ractor.make_shareable(
130
+ { "configuration" => ids(machine, machine_state.configuration),
131
+ "entered_states" => ids(machine, machine_state.entered_states),
132
+ "states_to_invoke" => ids(machine, machine_state.states_to_invoke),
133
+ "history_values" => history(machine, machine_state.history),
134
+ "active_invocations" => invocations(machine, machine_state.active_invocations),
135
+ **counters(machine_state), "datamodel" => datamodel, **flags(machine_state) }
136
+ )
137
+ end
138
+
139
+ # The recorded history by id, sorted by the history state's id.
140
+ def history(machine, recorded)
141
+ recorded.map { |key, values| [name(machine, key), ids(machine, values)] }.sort.to_h
142
+ end
143
+
144
+ # The id of the state at `index`.
145
+ def name(machine, index) = machine.state(index).id
146
+
147
+ # The ids of the states at `indexes`, the root left out, sorted.
148
+ def ids(machine, indexes) = (indexes.uniq - [0]).map { name(machine, _1) }.sort
149
+
150
+ # The live invocations, sorted by their state's id and their index.
151
+ def invocations(machine, active)
152
+ entries = active.map do |(state_index, invoke_index), invoke_id|
153
+ { "state" => name(machine, state_index), "invoke_index" => invoke_index, "invoke_id" => invoke_id }
154
+ end
155
+ entries.sort_by { [_1["state"], _1["invoke_index"]] }
156
+ end
157
+
158
+ # The three sequences, as the position carries them.
159
+ def counters(machine_state)
160
+ { "invoke_counter" => machine_state.invoke_counter, "send_counter" => machine_state.sends.send_counter,
161
+ "timer_counter" => machine_state.sends.timer_counter }
162
+ end
163
+
164
+ # The fields after the datamodel, as the position carries them.
165
+ def flags(machine_state)
166
+ budget = machine_state.max_macrostep_rounds
167
+ { "running" => machine_state.running, "status" => machine_state.status.to_s,
168
+ **machine_state.counters.transform_keys(&:to_s), "trace" => machine_state.trace,
169
+ "max_macrostep_rounds" => budget == :infinity ? "infinity" : budget }
170
+ end
171
+
172
+ # {Statifier.import_position}'s body: the position checked, its
173
+ # datamodel decoded, every id it names resolved against `machine`, and
174
+ # the state rebuilt.
175
+ #
176
+ # @return [Result]
177
+ def import(machine, position, processors)
178
+ types = DRIVER_CHECKS.registered_types(processors)
179
+ failure = Shape.failure(position)
180
+ return refused(:malformed_export, **failure) if failure
181
+
182
+ datamodel, failure = Text.decode(position["datamodel"])
183
+ return refused(:malformed_export, **failure) if datamodel.nil?
184
+
185
+ unknown = Shape.unknown_ids(machine, position)
186
+ return refused(:unknown_state_ids, ids: unknown) unless unknown.empty?
187
+
188
+ ok(Rebuild.state(machine, position, datamodel, types))
189
+ end
190
+
191
+ # A successful result.
192
+ def ok(value) = Result.new(value:, error: nil)
193
+
194
+ # A refused result, its detail frozen.
195
+ def refused(reason, **detail)
196
+ Result.new(value: nil, error: Refusal.new(reason:, detail: Ractor.make_shareable(detail)))
197
+ end
198
+ end
199
+
200
+ require_relative "position/text"
201
+ require_relative "position/shape"
202
+ require_relative "position/rebuild"
203
+
204
+ private_constant :Codec, :Rebuild, :SESSION_ID, :Shape, :Text
205
+ end
206
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Validator
5
+ # The check of the markup itself: an attribute name appears once on a
6
+ # tag (XML 1.0 section 3.1, "Unique Att Spec"). The reader keeps every
7
+ # repeated attribute and lowering keeps the first, so the repeat is read
8
+ # back out of the source: every start tag, markup inside `<content>`,
9
+ # `<assign>` and `<data>` included.
10
+ #
11
+ # @api private
12
+ module Attributes
13
+ module_function
14
+
15
+ # Each later occurrence of a name on one tag, at its own span; names
16
+ # are compared as written, prefix included.
17
+ def duplicates(_document, context)
18
+ parsed = XML.parse(context.source)
19
+ parsed.ok? ? element_duplicates(parsed.value) : []
20
+ end
21
+
22
+ # The repeats on `element` and on every element below it, in document
23
+ # order.
24
+ def element_duplicates(element)
25
+ seen = {}
26
+ own = element.attributes.filter_map do |attribute|
27
+ next seen[attribute.name] = nil unless seen.key?(attribute.name)
28
+
29
+ Findings.error(:duplicate_attribute, attribute.location, element: element.name, attribute: attribute.name)
30
+ end
31
+ own + element.children.grep(XML::Element).flat_map { element_duplicates(_1) }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Validator
5
+ # The index the checks share: built once per {Validator.validate} call
6
+ # from the chart and discarded with it, never kept or handed on.
7
+ #
8
+ # - `source`: the document as UTF-8, so that its byte offsets slice it;
9
+ # - `states`: every state in document order, a parent before its
10
+ # children;
11
+ # - `by_id`: each named state by its id; a repeated id keeps the last
12
+ # state written with it;
13
+ # - `parents`: each state's parent, another state or the {Document} for a
14
+ # top-level one, keyed by the state node itself (by identity);
15
+ # - `transitions`: every transition, as `[transition, owner]`, in
16
+ # document order; `owner` is `:plain` for a state's own, `:initial` for
17
+ # one inside a state's `<initial>`, and `:history` for a history's own;
18
+ # - `invoke_content_markup`: whether a root that declares no namespace
19
+ # is accepted.
20
+ #
21
+ # @api private
22
+ Context = ::Data.define(:source, :states, :by_id, :parents, :transitions, :invoke_content_markup) do
23
+ # Whether `state` sits inside `ancestor`, by the tree's structure: a
24
+ # state with no id, and two states that share one, are placed all the
25
+ # same.
26
+ def inside?(state, ancestor)
27
+ parent = parents.fetch(state)
28
+ parent.is_a?(Document::State) && (parent.equal?(ancestor) || inside?(parent, ancestor))
29
+ end
30
+ end
31
+
32
+ # Building the {Context}.
33
+ #
34
+ # @api private
35
+ module Index
36
+ module_function
37
+
38
+ # The {Context} of `document`, read from `source`.
39
+ def build(document, source, invoke_content_markup)
40
+ states = []
41
+ parents = {}.compare_by_identity
42
+ walk(document.states, document, states, parents)
43
+ by_id = states.each_with_object({}) { |state, ids| ids[state.id] = state unless state.id.nil? }
44
+ Context.new(source:, states: states.freeze, by_id: by_id.freeze, parents: parents.freeze,
45
+ transitions: states.flat_map { owned_transitions(_1) }.freeze, invoke_content_markup:)
46
+ end
47
+
48
+ # Collects every state below `parent` in document order, with its
49
+ # parent.
50
+ def walk(children, parent, states, parents)
51
+ children.each do |state|
52
+ states << state
53
+ parents[state] = parent
54
+ walk(state.states, state, states, parents)
55
+ end
56
+ end
57
+
58
+ # A state's own transitions, then its `<initial>` element's; a
59
+ # history's own only.
60
+ def owned_transitions(state)
61
+ return state.transitions.map { [_1, :history] } if state.kind == :history
62
+
63
+ initial = state.initial_element&.transitions || Lowering::Reading::EMPTY
64
+ state.transitions.map { [_1, :plain] } + initial.map { [_1, :initial] }
65
+ end
66
+
67
+ # Whether `node` is a `<state>` or `<parallel>` with children to
68
+ # enter; the root is not.
69
+ def compound?(node)
70
+ node.is_a?(Document::State) && %i[state parallel].include?(node.kind) && !node.states.empty?
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Validator
5
+ # The checks of `<donedata>`, `<content>`, `<param>`, `<datamodel>` and
6
+ # `<data>`.
7
+ #
8
+ # @api private
9
+ module DataChecks
10
+ # The rules of one `<data>`, in the reference's order.
11
+ DATA_RULES = {
12
+ data_expr_and_src: ->(data) { !data.expr.nil? && !data.src.nil? },
13
+ data_value_and_children: lambda do |data|
14
+ (!data.expr.nil? || !data.src.nil?) && !Lowering::Reading.blank?(data.text)
15
+ end,
16
+ data_reserved_id: ->(data) { data.id.start_with?("_") }
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ # `<donedata>` sits on a `<final>` only, and holds a `<content>` or
22
+ # `<param>` children, not both; each reported at the `<donedata>`.
23
+ def donedata(_document, context)
24
+ carriers = context.states.reject { _1.donedata.nil? }
25
+ both = carriers.select { |state| state.donedata.content && !state.donedata.params.empty? }
26
+ at_donedata(:donedata_not_on_final, carriers.reject { _1.kind == :final }) +
27
+ at_donedata(:donedata_content_and_params, both)
28
+ end
29
+
30
+ # The `reason` error of each state, at its `<donedata>`.
31
+ def at_donedata(reason, states) = states.map { Findings.error(reason, _1.donedata.location, id: _1.id) }
32
+
33
+ # A `<content>` under `<donedata>` or `<invoke>` does not carry both
34
+ # `expr` and a payload, markup or text that is not blank.
35
+ def contents(_document, context)
36
+ all = context.states.flat_map { [_1.donedata&.content, *_1.invoke.map(&:content)].compact }
37
+ all.filter_map do |content|
38
+ wording = payload(content)
39
+ Findings.error(:content_expr_and_text, content.location, wording:, expr: content.expr) if wording
40
+ end
41
+ end
42
+
43
+ # What a `<content>` with `expr` also carries: `"markup"`, `"text"`,
44
+ # or nil.
45
+ def payload(content)
46
+ return if content.expr.nil?
47
+ return "markup" if content.markup
48
+
49
+ "text" unless Lowering::Reading.blank?(content.text)
50
+ end
51
+
52
+ # A `<param>` under `<donedata>`, `<invoke>` or `<send>` carries exactly
53
+ # one of `expr` and `location`.
54
+ def params(_document, context)
55
+ state_params = context.states.flat_map { [*_1.donedata&.params, *_1.invoke.flat_map(&:params)] }
56
+ (state_params + Executable.sends(context).flat_map(&:params)).filter_map { param_error(_1) }
57
+ end
58
+
59
+ # The error of a `<param>` that writes neither or both, or nil.
60
+ def param_error(param)
61
+ written = [param.expr, param.param_location].compact.size
62
+ return if written == 1
63
+
64
+ Findings.error(written.zero? ? :param_no_value : :param_expr_and_location, param.location, name: param.name)
65
+ end
66
+
67
+ # `<data>`: not both `expr` and `src`, no child text beside either, no
68
+ # id beginning `_`; each at the id's span. Then each `<datamodel>` on a
69
+ # `<final>` or a history.
70
+ def data(document, context)
71
+ datamodels = [document.datamodel_element, *context.states.map(&:datamodel_element)].compact
72
+ datamodels.flat_map(&:data).flat_map { data_errors(_1) } + misplaced_datamodels(context)
73
+ end
74
+
75
+ # The errors of one `<data>`.
76
+ def data_errors(data)
77
+ location = States.span(data, :id)
78
+ DATA_RULES.select { |_, broken| broken.call(data) }.keys.map { Findings.error(_1, location, id: data.id) }
79
+ end
80
+
81
+ # A `<datamodel>` on a `<final>` or a history.
82
+ def misplaced_datamodels(context)
83
+ context.states.select { %i[final history].include?(_1.kind) && _1.datamodel_element }.map do |state|
84
+ Findings.error(:datamodel_bad_parent, state.datamodel_element.location, kind: state.kind)
85
+ end
86
+ end
87
+ end
88
+
89
+ # The checks of the root's namespace and version, and of the enumerated
90
+ # attribute values as written.
91
+ #
92
+ # @api private
93
+ module Enums
94
+ TRANSITION_TYPES = %w[internal external].freeze
95
+ BINDINGS = %w[early late].freeze
96
+ DATAMODELS = %w[predicator elixir null ecmascript xpath].freeze
97
+ AUTOFORWARDS = %w[true false].freeze
98
+
99
+ module_function
100
+
101
+ # The root resolves to the SCXML namespace (or, for invoke content
102
+ # markup, to none) and its version is `"1.0"`; each reported at the
103
+ # attribute's span, or the root's when it was not written.
104
+ def boilerplate(document, context)
105
+ namespace = document.namespace
106
+ namespace_ok = namespace == Findings::SCXML || (context.invoke_content_markup && namespace.nil?)
107
+ errors = namespace_ok ? [] : [Findings.error(:bad_namespace, States.span(document, :xmlns), uri: namespace)]
108
+ return errors if document.version == "1.0"
109
+
110
+ errors + [Findings.error(:bad_version, States.span(document, :version), version: document.version)]
111
+ end
112
+
113
+ # Every transition's `type`, the root's `binding` and `datamodel`, and
114
+ # every `<invoke>`'s `autoforward`, as written: lowering maps a value
115
+ # out of range onto the default, so only the text tells them apart.
116
+ def enums(document, context)
117
+ transitions = context.transitions.map(&:first)
118
+ invokes = context.states.flat_map(&:invoke)
119
+ transitions.flat_map { out_of_range(_1, :type, TRANSITION_TYPES, context, :transition_bad_type) } +
120
+ out_of_range(document, :binding, BINDINGS, context, :scxml_bad_binding) +
121
+ out_of_range(document, :datamodel, DATAMODELS, context, :scxml_bad_datamodel) +
122
+ invokes.flat_map { out_of_range(_1, :autoforward, AUTOFORWARDS, context, :invoke_bad_autoforward) }
123
+ end
124
+
125
+ # The `reason` error of a written attribute whose raw text is not in
126
+ # `allowed`; an attribute not written is its default, in range.
127
+ def out_of_range(node, key, allowed, context, reason)
128
+ location = node.attribute_locations[key]
129
+ return [] if location.nil?
130
+
131
+ raw = location.slice(context.source)
132
+ allowed.include?(raw) ? [] : [Findings.error(reason, location, raw: raw.freeze)]
133
+ end
134
+ end
135
+ end
136
+ end