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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +601 -16
- data/lib/statifier/accepts.rb +167 -0
- data/lib/statifier/basichttp/decode.rb +142 -0
- data/lib/statifier/basichttp/encoding.rb +224 -0
- data/lib/statifier/basichttp/net_http_transport.rb +157 -0
- data/lib/statifier/basichttp/outbound.rb +87 -0
- data/lib/statifier/basichttp/processor.rb +170 -0
- data/lib/statifier/basichttp/request.rb +94 -0
- data/lib/statifier/basichttp/response.rb +34 -0
- data/lib/statifier/basichttp.rb +47 -0
- data/lib/statifier/compiler/content.rb +131 -0
- data/lib/statifier/compiler/error.rb +30 -0
- data/lib/statifier/compiler/expressions.rb +64 -0
- data/lib/statifier/compiler/numbering.rb +145 -0
- data/lib/statifier/compiler/parts.rb +144 -0
- data/lib/statifier/compiler/walk.rb +61 -0
- data/lib/statifier/compiler.rb +82 -0
- data/lib/statifier/datamodel/data_binding.rb +114 -0
- data/lib/statifier/datamodel/delay.rb +54 -0
- data/lib/statifier/datamodel/location.rb +145 -0
- data/lib/statifier/datamodel/program.rb +82 -0
- data/lib/statifier/datamodel/reason_value.rb +42 -0
- data/lib/statifier/datamodel/reasons.rb +88 -0
- data/lib/statifier/datamodel/stores.rb +169 -0
- data/lib/statifier/datamodel/system_variables.rb +69 -0
- data/lib/statifier/datamodel.rb +120 -0
- data/lib/statifier/document/data.rb +36 -0
- data/lib/statifier/document/executable.rb +69 -0
- data/lib/statifier/document/invoke.rb +17 -0
- data/lib/statifier/document/states.rb +40 -0
- data/lib/statifier/document.rb +51 -0
- data/lib/statifier/driver/calls.rb +183 -0
- data/lib/statifier/driver/checks.rb +110 -0
- data/lib/statifier/driver/delivery.rb +45 -0
- data/lib/statifier/driver/invocations.rb +129 -0
- data/lib/statifier/driver/mailbox.rb +120 -0
- data/lib/statifier/driver/routing.rb +193 -0
- data/lib/statifier/driver/session_id.rb +40 -0
- data/lib/statifier/driver/work.rb +196 -0
- data/lib/statifier/driver.rb +205 -0
- data/lib/statifier/effect.rb +208 -0
- data/lib/statifier/event.rb +67 -0
- data/lib/statifier/frozen.rb +22 -0
- data/lib/statifier/interpreter/blocks.rb +142 -0
- data/lib/statifier/interpreter/content.rb +228 -0
- data/lib/statifier/interpreter/entering.rb +141 -0
- data/lib/statifier/interpreter/exit_entry.rb +174 -0
- data/lib/statifier/interpreter/foreach.rb +83 -0
- data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
- data/lib/statifier/interpreter/invoke/passes.rb +143 -0
- data/lib/statifier/interpreter/invoke.rb +145 -0
- data/lib/statifier/interpreter/loop.rb +170 -0
- data/lib/statifier/interpreter/machine_state.rb +152 -0
- data/lib/statifier/interpreter/macrostep.rb +185 -0
- data/lib/statifier/interpreter/name_match.rb +43 -0
- data/lib/statifier/interpreter/selection.rb +221 -0
- data/lib/statifier/interpreter/send.rb +223 -0
- data/lib/statifier/interpreter/step.rb +138 -0
- data/lib/statifier/interpreter/stop.rb +66 -0
- data/lib/statifier/interpreter/targets.rb +107 -0
- data/lib/statifier/interpreter.rb +57 -0
- data/lib/statifier/lowering/branches.rb +84 -0
- data/lib/statifier/lowering/data.rb +102 -0
- data/lib/statifier/lowering/error.rb +102 -0
- data/lib/statifier/lowering/executable.rb +106 -0
- data/lib/statifier/lowering/placement.rb +65 -0
- data/lib/statifier/lowering/reading.rb +53 -0
- data/lib/statifier/lowering/states.rb +111 -0
- data/lib/statifier/lowering.rb +129 -0
- data/lib/statifier/machine/content.rb +81 -0
- data/lib/statifier/machine/data.rb +42 -0
- data/lib/statifier/machine/states.rb +56 -0
- data/lib/statifier/machine/values.rb +37 -0
- data/lib/statifier/machine.rb +103 -0
- data/lib/statifier/position/rebuild.rb +84 -0
- data/lib/statifier/position/shape.rb +73 -0
- data/lib/statifier/position/text.rb +43 -0
- data/lib/statifier/position.rb +206 -0
- data/lib/statifier/validator/attributes.rb +35 -0
- data/lib/statifier/validator/context.rb +74 -0
- data/lib/statifier/validator/data.rb +136 -0
- data/lib/statifier/validator/error.rb +90 -0
- data/lib/statifier/validator/executable.rb +139 -0
- data/lib/statifier/validator/history.rb +81 -0
- data/lib/statifier/validator/messages.rb +142 -0
- data/lib/statifier/validator/states.rb +161 -0
- data/lib/statifier/validator.rb +94 -0
- data/lib/statifier/version.rb +4 -1
- data/lib/statifier/xml/character_data.rb +142 -0
- data/lib/statifier/xml/cursor.rb +131 -0
- data/lib/statifier/xml/markup.rb +153 -0
- data/lib/statifier/xml/nodes.rb +50 -0
- data/lib/statifier/xml/parser.rb +121 -0
- data/lib/statifier/xml/scope.rb +46 -0
- data/lib/statifier/xml/tags.rb +112 -0
- data/lib/statifier/xml.rb +85 -0
- data/lib/statifier.rb +386 -2
- metadata +129 -20
- data/.gitignore +0 -8
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -33
- data/Rakefile +0 -10
- data/bin/console +0 -8
- data/bin/setup +0 -6
- 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
|