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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Interpreter
|
|
5
|
+
# The specification's `exitInterpreter`, as the reference ports it
|
|
6
|
+
# (`Statifier.Interpreter.exit_interpreter/1` in statifier-ex), with
|
|
7
|
+
# `returnDoneEvent` an effect: {Effect::Done}, answered last.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Stop
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# `exitInterpreter`: every active state leaves in exit order, running
|
|
14
|
+
# its `<onexit>` blocks and then cancelling its live invocations, with
|
|
15
|
+
# no history recorded; the top-level final
|
|
16
|
+
# among them, if any, yields the donedata, and with none the donedata
|
|
17
|
+
# is nil. {Effect::Done} comes last, the status becomes `:done` and
|
|
18
|
+
# the internal queue is emptied, taking any event the exits raised
|
|
19
|
+
# with it, so a stopped chart is quiescent; the first
|
|
20
|
+
# `error.execution` folding the donedata raised survives as
|
|
21
|
+
# `donedata_error`. With tracing on an {Effect::Trace::ExitSet} comes
|
|
22
|
+
# first and an {Effect::Trace::Done} just before {Effect::Done}.
|
|
23
|
+
#
|
|
24
|
+
# @return [Stepped]
|
|
25
|
+
def exit_interpreter(state)
|
|
26
|
+
order = state.configuration.reverse.freeze
|
|
27
|
+
exited, effects, donedata, error = order.reduce([state, [], nil, nil]) { |acc, index| leave(acc, index) }
|
|
28
|
+
trace = Blocks.traced(state, Effect::Trace::ExitSet, indexes: order, configuration: exited.configuration)
|
|
29
|
+
done = done_effects(exited, donedata:, donedata_error: error, configuration: state.configuration)
|
|
30
|
+
Stepped.new(state: exited.with(status: :done, internal_queue: [].freeze), effects: trace + effects + done)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The {Effect::Trace::Done} (when tracing) and the {Effect::Done} of a
|
|
34
|
+
# stop, the donedata and its error copied deeply frozen.
|
|
35
|
+
def done_effects(exited, donedata:, donedata_error:, configuration:)
|
|
36
|
+
fields = { donedata: Ractor.make_shareable(donedata, copy: true),
|
|
37
|
+
donedata_error: Ractor.make_shareable(donedata_error, copy: true), configuration: }
|
|
38
|
+
Blocks.traced(exited, Effect::Trace::Done, **fields) + [Effect::Done.new(**fields, **exited.counters)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# One state leaving at the stop: `[state, effects, donedata, error]`
|
|
42
|
+
# after it.
|
|
43
|
+
def leave((state, effects, donedata, error), index)
|
|
44
|
+
left = Blocks.run_blocks(state, :onexit, index)
|
|
45
|
+
left = left.then_step(Invoke::Passes.cancel_invocations(left.state, index))
|
|
46
|
+
state = left.state.with(configuration: (left.state.configuration - [index]).freeze)
|
|
47
|
+
donedata, error, state = collect_donedata(state, index) if top_level_final?(state.machine, index)
|
|
48
|
+
[state, effects + left.effects, donedata, error]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether the state is a final whose parent is the root.
|
|
52
|
+
def top_level_final?(machine, index) = machine.final?(index) && machine.state(index).parent.zero?
|
|
53
|
+
|
|
54
|
+
# The top-level final's donedata and the data of the first
|
|
55
|
+
# `error.execution` folding it raised, `[data, error, state]`: the
|
|
56
|
+
# queue is read either side of the fold, so an event already queued is
|
|
57
|
+
# never taken for its own.
|
|
58
|
+
def collect_donedata(state, index)
|
|
59
|
+
before = state.internal_queue.size
|
|
60
|
+
state, data = Blocks.donedata(state, index)
|
|
61
|
+
raised = state.internal_queue.drop(before).find { _1.name == "error.execution" }
|
|
62
|
+
[data, raised&.data, state]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Interpreter
|
|
5
|
+
# What a `<send>`'s resolved target and type name, and whether the core
|
|
6
|
+
# refuses them, as the reference judges them (`Statifier.Send.Target`,
|
|
7
|
+
# `Statifier.Send.Types` and `Statifier.Send.Routes` in statifier-ex).
|
|
8
|
+
# Pure over values: whether a destination exists is the driver's.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module Targets
|
|
12
|
+
# What the driver declares a session can reach: `sessions`, the
|
|
13
|
+
# session ids a `#_scxml_` target may name; `parent?`, whether the
|
|
14
|
+
# session has a parent (the reference's `parent?`, spelled the same);
|
|
15
|
+
# and `invokes`, the invoke ids a `#_` target may name. The session
|
|
16
|
+
# itself and its internal queue need no entry.
|
|
17
|
+
Routes = ::Data.define(:sessions, :parent?, :invokes) do
|
|
18
|
+
# Whether `route` (a {Route}) is reachable.
|
|
19
|
+
def reachable?(route)
|
|
20
|
+
case route.kind
|
|
21
|
+
when :self, :internal then true
|
|
22
|
+
when :session then sessions.include?(route.id)
|
|
23
|
+
when :parent then parent?
|
|
24
|
+
when :invoke then invokes.include?(route.id)
|
|
25
|
+
else false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# What a resolved target names: `kind` one of `:self` (no target),
|
|
31
|
+
# `:internal`, `:parent`, `:session` and `:invoke` (with `id`, the
|
|
32
|
+
# session or invoke id), and `:invalid` (with `id`, the target as
|
|
33
|
+
# resolved).
|
|
34
|
+
Route = ::Data.define(:kind, :id)
|
|
35
|
+
|
|
36
|
+
# The type is neither the SCXML processor's nor a registered one.
|
|
37
|
+
UnsupportedType = ::Data.define(:type)
|
|
38
|
+
|
|
39
|
+
# The target names nothing the SCXML processor carries.
|
|
40
|
+
InvalidTarget = ::Data.define(:target)
|
|
41
|
+
|
|
42
|
+
# The declared routes do not reach the target.
|
|
43
|
+
UnreachableTarget = ::Data.define(:target)
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Parses a resolved target. Both spellings of the internal and parent
|
|
48
|
+
# targets, with and without the leading `#`, are accepted, as the
|
|
49
|
+
# reference accepts them; anything that is not one of the special
|
|
50
|
+
# targets is invalid.
|
|
51
|
+
#
|
|
52
|
+
# @return [Route]
|
|
53
|
+
def parse_target(target)
|
|
54
|
+
return Route.new(kind: :self, id: nil) if target.nil?
|
|
55
|
+
return Route.new(kind: :invalid, id: target) unless target.is_a?(String)
|
|
56
|
+
|
|
57
|
+
case target
|
|
58
|
+
when "#_internal", "_internal" then Route.new(kind: :internal, id: nil)
|
|
59
|
+
when "#_parent", "_parent" then Route.new(kind: :parent, id: nil)
|
|
60
|
+
when /\A#_scxml_(.+)\z/m then Route.new(kind: :session, id: ::Regexp.last_match(1))
|
|
61
|
+
when /\A#_(.+)\z/m then Route.new(kind: :invoke, id: ::Regexp.last_match(1))
|
|
62
|
+
else Route.new(kind: :invalid, id: target)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether the SCXML processor carries `type`: no type, its short form
|
|
67
|
+
# `scxml`, or its URI.
|
|
68
|
+
def built_in_type?(type)
|
|
69
|
+
type.nil? || type == "scxml" || type == Datamodel::SystemVariables::SCXML_EVENT_PROCESSOR
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# How a resolved type is carried: `:built_in`, `:registered` (a String
|
|
73
|
+
# in `send_types`, the registered types or nil for none) or
|
|
74
|
+
# `:unsupported`. A built-in spelling stays built in whatever the host
|
|
75
|
+
# registered.
|
|
76
|
+
def classify(send_types, type)
|
|
77
|
+
return :built_in if built_in_type?(type)
|
|
78
|
+
return :registered if type.is_a?(String) && send_types&.include?(type)
|
|
79
|
+
|
|
80
|
+
:unsupported
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Why a send is refused, as `[kind, reason]` with `kind` the `error.*`
|
|
84
|
+
# event it raises (`:execution` or `:communication`), or nil. The type
|
|
85
|
+
# is judged first; a registered type's target is its processor's own
|
|
86
|
+
# and never parsed; with `routes` declared, an immediate send's target
|
|
87
|
+
# must be reachable, while a delayed one (`delay_ms` set) is routed
|
|
88
|
+
# when it fires.
|
|
89
|
+
def reject_reason(target, type, delay_ms, send_types, routes)
|
|
90
|
+
case classify(send_types, type)
|
|
91
|
+
when :unsupported then [:execution, UnsupportedType.new(type:)]
|
|
92
|
+
when :registered then nil
|
|
93
|
+
else target_reason(target, delay_ms, routes)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A target's refusal under the SCXML processor, or nil.
|
|
98
|
+
def target_reason(target, delay_ms, routes)
|
|
99
|
+
route = parse_target(target)
|
|
100
|
+
return [:execution, InvalidTarget.new(target:)] if route.kind == :invalid
|
|
101
|
+
return nil if routes.nil? || !delay_ms.nil? || routes.reachable?(route)
|
|
102
|
+
|
|
103
|
+
[:communication, UnreachableTarget.new(target:)]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "machine"
|
|
4
|
+
require_relative "datamodel"
|
|
5
|
+
require_relative "frozen"
|
|
6
|
+
require_relative "effect"
|
|
7
|
+
require_relative "event"
|
|
8
|
+
|
|
9
|
+
module Statifier
|
|
10
|
+
# The interpreter's algorithm, ported function for function from the
|
|
11
|
+
# specification's Appendix D pseudocode as the reference ports it: which
|
|
12
|
+
# transitions an event enables ({Selection}, with the event-name matcher
|
|
13
|
+
# {NameMatch}), what those transitions enter ({Entering}), and which
|
|
14
|
+
# states they leave and enter, in order ({ExitEntry}); and what a block of
|
|
15
|
+
# executable content does ({Content}, with `<send>` and `<cancel>` in
|
|
16
|
+
# {Send}); and the loop over them ({Loop}, with {Step}, {Blocks},
|
|
17
|
+
# {Macrostep} and {Stop}), which starts a chart, takes an event and runs
|
|
18
|
+
# each macrostep over a {MachineState}.
|
|
19
|
+
#
|
|
20
|
+
# Every function here is pure over plain values: a machine, a
|
|
21
|
+
# configuration, the recorded history, and, where an expression is
|
|
22
|
+
# evaluated, the datamodel. Below the loop none of them moves a queue or
|
|
23
|
+
# keeps state; each answers what the step that calls it needs as a frozen
|
|
24
|
+
# value (the datamodel a block leaves, its effects and the events it raised
|
|
25
|
+
# included). The loop threads one {MachineState} through them and answers
|
|
26
|
+
# the state it leaves, never changing the one it was given.
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
module Interpreter
|
|
30
|
+
# What selection, exit and entry read of an execution, the two globals
|
|
31
|
+
# the pseudocode keeps beside the chart: `machine`, the compiled chart;
|
|
32
|
+
# `configuration`, the indexes of the active states as a frozen Array in
|
|
33
|
+
# ascending index order (document order); and `history`, the recorded
|
|
34
|
+
# value of each history state that has one, a frozen Hash of the history
|
|
35
|
+
# state's index to a frozen ascending Array of state indexes.
|
|
36
|
+
Snapshot = ::Data.define(:machine, :configuration, :history) do
|
|
37
|
+
# Whether the state at `index` is active.
|
|
38
|
+
def active?(index) = configuration.include?(index)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
require_relative "interpreter/name_match"
|
|
44
|
+
require_relative "interpreter/selection"
|
|
45
|
+
require_relative "interpreter/entering"
|
|
46
|
+
require_relative "interpreter/exit_entry"
|
|
47
|
+
require_relative "interpreter/targets"
|
|
48
|
+
require_relative "interpreter/send"
|
|
49
|
+
require_relative "interpreter/content"
|
|
50
|
+
require_relative "interpreter/foreach"
|
|
51
|
+
require_relative "interpreter/machine_state"
|
|
52
|
+
require_relative "interpreter/blocks"
|
|
53
|
+
require_relative "interpreter/invoke"
|
|
54
|
+
require_relative "interpreter/step"
|
|
55
|
+
require_relative "interpreter/macrostep"
|
|
56
|
+
require_relative "interpreter/stop"
|
|
57
|
+
require_relative "interpreter/loop"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Lowering
|
|
5
|
+
# The builders of `<if>`, `<elseif>`, `<else>` and `<foreach>`.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Executable
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# An `<if>` is folded into partitions: the first branch is the `<if>`'s
|
|
12
|
+
# own, each `<elseif>` or `<else>` opens the next, and executable
|
|
13
|
+
# content joins whichever branch is open.
|
|
14
|
+
def if_node(element, source)
|
|
15
|
+
results, errors = Lowering.walk_children(element, source)
|
|
16
|
+
cond = Reading.value(element, "cond")
|
|
17
|
+
return [nil, errors + [Errors.missing_attribute("if", "cond", element.location)]] if cond.nil?
|
|
18
|
+
|
|
19
|
+
first = branch(element, cond, Reading.locations(element, %w[cond]))
|
|
20
|
+
branches, misplaced = partition(results, first, element.name)
|
|
21
|
+
[[:content_node, Document::If.new(location: element.location, branches:)], errors + misplaced]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# An `<elseif>` takes no children; it is a partition mark for its `<if>`.
|
|
25
|
+
def elseif(element, _source)
|
|
26
|
+
errors = misplaced_children(element, "elseif")
|
|
27
|
+
cond = Reading.value(element, "cond")
|
|
28
|
+
return [nil, errors + [Errors.missing_attribute("elseif", "cond", element.location)]] if cond.nil?
|
|
29
|
+
|
|
30
|
+
[[:elseif, branch(element, cond, Reading.locations(element, %w[cond]))], errors]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An `<else>` takes no attributes: the first one written is refused,
|
|
34
|
+
# and the branch is made all the same.
|
|
35
|
+
def else_node(element, _source)
|
|
36
|
+
errors = misplaced_children(element, "else")
|
|
37
|
+
first = element.attributes.first
|
|
38
|
+
errors += [Errors.unexpected_attribute("else", first.name, first.location)] if first
|
|
39
|
+
[[:else, branch(element, nil, Reading::NO_LOCATIONS)], errors]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Both required attributes are checked before the node is given up, so
|
|
43
|
+
# a `<foreach>` missing both reports both.
|
|
44
|
+
def foreach(element, source)
|
|
45
|
+
results, errors = Lowering.walk_children(element, source)
|
|
46
|
+
missing = %w[item array].select { |name| Reading.value(element, name).nil? }
|
|
47
|
+
unless missing.empty?
|
|
48
|
+
return [nil, errors + missing.map { Errors.missing_attribute("foreach", _1, element.location) }]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
filled, misplaced = Placement.fold(results, Placement::CONTENT, element.name)
|
|
52
|
+
[[:content_node, foreach_node(element, Placement.many(filled, :content))], errors + misplaced]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The node of a `<foreach>` whose required attributes were written.
|
|
56
|
+
def foreach_node(element, content)
|
|
57
|
+
Document::Foreach.new(
|
|
58
|
+
location: element.location, array: Reading.value(element, "array"), item: Reading.value(element, "item"),
|
|
59
|
+
index: Reading.value(element, "index"), content:,
|
|
60
|
+
attribute_locations: Reading.locations(element, %w[array item index])
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A branch opened by `element`, holding no content yet.
|
|
65
|
+
def branch(element, cond, attribute_locations)
|
|
66
|
+
Document::If::Branch.new(location: element.location, cond:, content: Reading::EMPTY, attribute_locations:)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Folds an `<if>`'s lowered children into its branches.
|
|
70
|
+
def partition(results, first, parent_name)
|
|
71
|
+
open = [[first, []]]
|
|
72
|
+
errors = []
|
|
73
|
+
results.compact.each do |tag, node|
|
|
74
|
+
case tag
|
|
75
|
+
when :content_node then open.last.last << node
|
|
76
|
+
when :elseif, :else then open << [node, []]
|
|
77
|
+
else errors.unshift(Placement.misplaced(tag, node, parent_name))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
[open.map { |opened, content| opened.with(content: content.freeze) }.freeze, errors]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Lowering
|
|
5
|
+
# The builders of `<datamodel>`, `<data>`, `<donedata>`, `<param>`,
|
|
6
|
+
# `<content>` and `<invoke>`, and the markup slice `<content>` and
|
|
7
|
+
# `<assign>` share.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module DataModel
|
|
11
|
+
DATAMODEL_SLOTS = { data: %i[data many] }.freeze
|
|
12
|
+
DONEDATA_SLOTS = { content: %i[content one], param: %i[params many] }.freeze
|
|
13
|
+
INVOKE_SLOTS = { param: %i[params many], content: %i[content one], finalize: %i[finalize one] }.freeze
|
|
14
|
+
INVOKE_ATTRIBUTES = %w[type typeexpr src srcexpr id idlocation].freeze
|
|
15
|
+
AUTOFORWARD = { "true" => true, "false" => false }.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# A `<datamodel>`: its `<data>` children.
|
|
20
|
+
def datamodel(element, source)
|
|
21
|
+
filled, errors = States.lower_children(element, source, DATAMODEL_SLOTS)
|
|
22
|
+
[[:datamodel, Document::Datamodel.new(location: element.location, data: Placement.many(filled, :data))],
|
|
23
|
+
errors]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A `<data>` holds text, not markup: its text is its value and each
|
|
27
|
+
# element child is misplaced.
|
|
28
|
+
def data(element, _source)
|
|
29
|
+
errors = Executable.misplaced_children(element, "data")
|
|
30
|
+
id = Reading.value(element, "id")
|
|
31
|
+
return [nil, errors + [Errors.missing_attribute("data", "id", element.location)]] if id.nil?
|
|
32
|
+
|
|
33
|
+
node = Document::Data.new(
|
|
34
|
+
location: element.location, id:, expr: Reading.value(element, "expr"),
|
|
35
|
+
src: Reading.value(element, "src"), text: Reading.text(element),
|
|
36
|
+
attribute_locations: Reading.locations(element, %w[id expr src])
|
|
37
|
+
)
|
|
38
|
+
[[:data, node], errors]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A `<donedata>`: its `<param>` children and its last `<content>`.
|
|
42
|
+
def donedata(element, source)
|
|
43
|
+
filled, errors = States.lower_children(element, source, DONEDATA_SLOTS)
|
|
44
|
+
node = Document::Donedata.new(location: element.location, content: filled[:content],
|
|
45
|
+
params: Placement.many(filled, :params))
|
|
46
|
+
[[:donedata, node], errors]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A `<param>`, which needs its `name`; its `location` attribute is
|
|
50
|
+
# `param_location`.
|
|
51
|
+
def param(element, source)
|
|
52
|
+
Executable.required(element, source, Executable::NO_SLOTS, "name") do |name, _filled|
|
|
53
|
+
[:param, Document::Param.new(
|
|
54
|
+
location: element.location, name:, expr: Reading.value(element, "expr"),
|
|
55
|
+
param_location: Reading.value(element, "location"),
|
|
56
|
+
attribute_locations: Reading.locations(element, %w[name expr location])
|
|
57
|
+
)]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The children of a `<content>` are its payload and are never walked.
|
|
62
|
+
def content(element, source)
|
|
63
|
+
markup, markup_location = markup(element, source)
|
|
64
|
+
node = Document::Content.new(
|
|
65
|
+
location: element.location, expr: Reading.value(element, "expr"), text: Reading.text(element),
|
|
66
|
+
markup:, markup_location:, attribute_locations: Reading.locations(element, %w[expr])
|
|
67
|
+
)
|
|
68
|
+
[[:content, node], Reading::EMPTY]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An `<invoke>`: its attributes, its `<param>` children, and its last
|
|
72
|
+
# `<content>` and `<finalize>`.
|
|
73
|
+
def invoke(element, source)
|
|
74
|
+
filled, errors = States.lower_children(element, source, INVOKE_SLOTS)
|
|
75
|
+
fields = INVOKE_ATTRIBUTES.to_h { |name| [name.to_sym, Reading.value(element, name)] }
|
|
76
|
+
node = Document::Invoke.new(
|
|
77
|
+
location: element.location, **fields, namelist: Reading.list(element, "namelist"),
|
|
78
|
+
autoforward: Reading.choice(element, "autoforward", AUTOFORWARD, false),
|
|
79
|
+
params: Placement.many(filled, :params), content: filled[:content], finalize: filled[:finalize],
|
|
80
|
+
attribute_locations: Reading.locations(element, [*INVOKE_ATTRIBUTES, "namelist", "autoforward"])
|
|
81
|
+
)
|
|
82
|
+
[[:invoke, node], errors]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The source of an element's children from the first that is not blank
|
|
86
|
+
# text to the last, line breaks as written, and its span; both nil when
|
|
87
|
+
# it has no element child.
|
|
88
|
+
def markup(element, source)
|
|
89
|
+
significant = element.children.reject { |child| child.is_a?(XML::Text) && Reading.blank?(child.value) }
|
|
90
|
+
return [nil, nil] unless significant.any?(XML::Element)
|
|
91
|
+
|
|
92
|
+
span = across(significant.first.location, significant.last.location)
|
|
93
|
+
[span.slice(source).freeze, span]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The span from the start of `first` to the end of `last`.
|
|
97
|
+
def across(first, last)
|
|
98
|
+
first.with(end_line: last.end_line, end_column: last.end_column, end_offset: last.end_offset)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Lowering
|
|
5
|
+
# Why a tree did not lower: a reason symbol, the detail that reason
|
|
6
|
+
# carries (a frozen Hash), a message for a person, and the span of the
|
|
7
|
+
# element, attribute or text refused. The reasons and their details:
|
|
8
|
+
#
|
|
9
|
+
# - `:unsupported_element` (`name`): an element in SCXML's vocabulary
|
|
10
|
+
# that no builder reads, `<scxml>` below the root included;
|
|
11
|
+
# - `:misplaced_element` (`name`, `parent`): an element its parent has
|
|
12
|
+
# no place for;
|
|
13
|
+
# - `:stray_text` (`text`, trimmed): text that is not blank where the
|
|
14
|
+
# content model has no room for it;
|
|
15
|
+
# - `:unexpected_root` (`name`): a root that is not `<scxml>`;
|
|
16
|
+
# - `:missing_attribute` (`element`, `attribute`): a required attribute
|
|
17
|
+
# absent, so the element's node cannot be made;
|
|
18
|
+
# - `:foreign_element` (`name`, `uri`): an element in another namespace;
|
|
19
|
+
# - `:unexpected_attribute` (`element`, `attribute`): an attribute on
|
|
20
|
+
# `<else>`, which takes none;
|
|
21
|
+
# - `:unsupported_attribute` (`element`, `attribute`): `<script src>`,
|
|
22
|
+
# which the specification defines and this engine does not fetch.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
Error = ::Data.define(:reason, :detail, :message, :location)
|
|
26
|
+
|
|
27
|
+
# The constructors of {Error}, one per reason, with the reference's
|
|
28
|
+
# messages.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
module Errors
|
|
32
|
+
# The edge characters a trim removes: Unicode white space.
|
|
33
|
+
TRIM = "\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000"
|
|
34
|
+
TRIM_EDGES = /\A[#{TRIM}]+|[#{TRIM}]+\z/
|
|
35
|
+
PREVIEW = 40
|
|
36
|
+
ESCAPES = { "\\" => "\\\\", '"' => "\\\"", "\n" => "\\n", "\t" => "\\t", "\r" => "\\r", "\#{" => "\\\#{" }.freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# @return [Error] `:unsupported_element`
|
|
41
|
+
def unsupported(name, location)
|
|
42
|
+
make(:unsupported_element, { name: }, "unsupported element #{quote(name)}", location)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Error] `:misplaced_element`
|
|
46
|
+
def misplaced(name, parent, location)
|
|
47
|
+
make(:misplaced_element, { name:, parent: },
|
|
48
|
+
"element #{quote(name)} is not allowed inside #{quote(parent)}", location)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Error] `:stray_text`, the message quoting at most the first
|
|
52
|
+
# 40 characters of the trimmed text
|
|
53
|
+
def stray_text(text, location)
|
|
54
|
+
trimmed = trim(text)
|
|
55
|
+
clusters = trimmed.grapheme_clusters
|
|
56
|
+
preview = clusters.size > PREVIEW ? "#{clusters.first(PREVIEW).join}..." : trimmed
|
|
57
|
+
make(:stray_text, { text: trimmed }, "stray text #{quote(preview)} is not allowed here", location)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Error] `:unexpected_root`
|
|
61
|
+
def unexpected_root(name, location)
|
|
62
|
+
make(:unexpected_root, { name: }, "expected the document root to be <scxml>, got #{quote(name)}", location)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Error] `:missing_attribute`
|
|
66
|
+
def missing_attribute(element, attribute, location)
|
|
67
|
+
make(:missing_attribute, { element:, attribute: },
|
|
68
|
+
"element #{quote(element)} is missing required attribute #{quote(attribute)}", location)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Error] `:foreign_element`
|
|
72
|
+
def foreign_element(name, uri, location)
|
|
73
|
+
make(:foreign_element, { name:, uri: },
|
|
74
|
+
"element #{quote(name)} is bound to foreign namespace #{quote(uri)}, not SCXML", location)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Error] `:unexpected_attribute`
|
|
78
|
+
def unexpected_attribute(element, attribute, location)
|
|
79
|
+
make(:unexpected_attribute, { element:, attribute: },
|
|
80
|
+
"element #{quote(element)} does not accept attribute #{quote(attribute)}", location)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Error] `:unsupported_attribute`
|
|
84
|
+
def unsupported_attribute(element, attribute, location)
|
|
85
|
+
make(:unsupported_attribute, { element:, attribute: },
|
|
86
|
+
"element #{quote(element)}'s #{quote(attribute)} attribute is defined by the spec " \
|
|
87
|
+
"but not implemented by this engine", location)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `text` with Unicode white space trimmed from both ends.
|
|
91
|
+
def trim(text) = text.gsub(TRIM_EDGES, "").freeze
|
|
92
|
+
|
|
93
|
+
# `text` in double quotes, escaped as the reference quotes it.
|
|
94
|
+
def quote(text) = %("#{text.gsub(/\\|"|\n|\t|\r|\#\{/, ESCAPES)}").freeze
|
|
95
|
+
|
|
96
|
+
# An {Error} with its detail and message frozen.
|
|
97
|
+
def make(reason, detail, message, location)
|
|
98
|
+
Error.new(reason:, detail: detail.transform_values(&:freeze).freeze, message: message.freeze, location:)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Lowering
|
|
5
|
+
# The builders of executable content. A node that a required attribute
|
|
6
|
+
# is missing from is not made: its builder answers nil and the error.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module Executable
|
|
10
|
+
NO_SLOTS = {}.freeze
|
|
11
|
+
SEND_SLOTS = { param: %i[params many], content: %i[content one] }.freeze
|
|
12
|
+
SEND_ATTRIBUTES = %w[event eventexpr target targetexpr type typeexpr id idlocation delay delayexpr].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# A `<raise>`'s `event` is one string, not split.
|
|
17
|
+
def raise_node(element, source)
|
|
18
|
+
required(element, source, NO_SLOTS, "event") do |event, _filled|
|
|
19
|
+
[:content_node, Document::Raise.new(location: element.location, event:,
|
|
20
|
+
attribute_locations: Reading.locations(element, %w[event]))]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A `<log>`: its `label` and `expr`; it takes no children.
|
|
25
|
+
def log(element, source)
|
|
26
|
+
_filled, errors = States.lower_children(element, source, NO_SLOTS)
|
|
27
|
+
node = Document::Log.new(location: element.location, label: Reading.value(element, "label"),
|
|
28
|
+
expr: Reading.value(element, "expr"),
|
|
29
|
+
attribute_locations: Reading.locations(element, %w[label expr]))
|
|
30
|
+
[[:content_node, node], errors]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An `<assign>`'s children are its in-line value and are never walked.
|
|
34
|
+
def assign(element, source)
|
|
35
|
+
path = Reading.value(element, "location")
|
|
36
|
+
return [nil, [Errors.missing_attribute("assign", "location", element.location)]] if path.nil?
|
|
37
|
+
|
|
38
|
+
markup, markup_location = DataModel.markup(element, source)
|
|
39
|
+
node = Document::Assign.new(
|
|
40
|
+
location: path, node_location: element.location, expr: Reading.value(element, "expr"),
|
|
41
|
+
text: Reading.text(element), markup:, markup_location:,
|
|
42
|
+
attribute_locations: Reading.locations(element, %w[location expr])
|
|
43
|
+
)
|
|
44
|
+
[[:content_node, node], Reading::EMPTY]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A `<send>`: its attributes, its `<param>` children and its last
|
|
48
|
+
# `<content>`.
|
|
49
|
+
def send_node(element, source)
|
|
50
|
+
filled, errors = States.lower_children(element, source, SEND_SLOTS)
|
|
51
|
+
fields = SEND_ATTRIBUTES.to_h { |name| [name.to_sym, Reading.value(element, name)] }
|
|
52
|
+
node = Document::Send.new(
|
|
53
|
+
location: element.location, **fields, namelist: Reading.list(element, "namelist"),
|
|
54
|
+
params: Placement.many(filled, :params), content: filled[:content],
|
|
55
|
+
attribute_locations: Reading.locations(element, [*SEND_ATTRIBUTES, "namelist"])
|
|
56
|
+
)
|
|
57
|
+
[[:content_node, node], errors]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A `<cancel>`: its `sendid` and `sendidexpr`; it takes no children.
|
|
61
|
+
def cancel(element, source)
|
|
62
|
+
_filled, errors = States.lower_children(element, source, NO_SLOTS)
|
|
63
|
+
node = Document::Cancel.new(location: element.location, sendid: Reading.value(element, "sendid"),
|
|
64
|
+
sendidexpr: Reading.value(element, "sendidexpr"),
|
|
65
|
+
attribute_locations: Reading.locations(element, %w[sendid sendidexpr]))
|
|
66
|
+
[[:content_node, node], errors]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A `<script>`'s text is its program. A `src` names a program to fetch,
|
|
70
|
+
# and nothing is fetched, so a `<script src>` does not lower.
|
|
71
|
+
def script(element, _source)
|
|
72
|
+
errors = misplaced_children(element, "script")
|
|
73
|
+
if Reading.attribute(element, "src")
|
|
74
|
+
return [nil, errors + [Errors.unsupported_attribute("script", "src", element.location)]]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
node = Document::Script.new(location: element.location, text: Reading.text(element), src: nil,
|
|
78
|
+
attribute_locations: Reading::NO_LOCATIONS)
|
|
79
|
+
[[:content_node, node], errors]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Lowers an element whose node needs the attribute `name`: walks its
|
|
83
|
+
# children, then answers nil and the missing-attribute error when it is
|
|
84
|
+
# absent, or the node the block makes from its value and the filled
|
|
85
|
+
# fields (the block answers it tagged), with the misplaced children.
|
|
86
|
+
def required(element, source, slots, name)
|
|
87
|
+
results, errors = Lowering.walk_children(element, source)
|
|
88
|
+
value = Reading.value(element, name)
|
|
89
|
+
return [nil, errors + [Errors.missing_attribute(local(element), name, element.location)]] if value.nil?
|
|
90
|
+
|
|
91
|
+
filled, misplaced = Placement.fold(results, slots, element.name)
|
|
92
|
+
[yield(value, filled), errors + misplaced]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Each element child of an element that holds only text, misplaced.
|
|
96
|
+
def misplaced_children(element, parent)
|
|
97
|
+
element.children.grep(XML::Element).map { |child| Errors.misplaced(child.name, parent, child.location) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The local name of `element`, its prefix dropped.
|
|
101
|
+
def local(element) = element.name.split(":", 2).last
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
require_relative "branches"
|