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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Validator
|
|
5
|
+
# Why a chart does not validate: a reason symbol, the detail that reason
|
|
6
|
+
# carries (a frozen Hash), a message for a person, and the span reported.
|
|
7
|
+
# The shape of {Lowering::Error}. An error gates compilation: a chart
|
|
8
|
+
# with one is not compiled.
|
|
9
|
+
#
|
|
10
|
+
# The reasons and their detail, each the reference's:
|
|
11
|
+
#
|
|
12
|
+
# - ids: `:duplicate_id` (`id`), `:empty_id`;
|
|
13
|
+
# - targets: `:unresolved_target` (`id`), `:unresolved_initial` (`id`),
|
|
14
|
+
# `:initial_not_descendant` (`id`, `parent_id`),
|
|
15
|
+
# `:initial_on_atomic_state` (`id`);
|
|
16
|
+
# - default transitions: `:initial_attribute_and_element` (`id`),
|
|
17
|
+
# `:transition_count` (`owner`, `count`), `:transition_missing_target`
|
|
18
|
+
# (`owner`), `:transition_forbidden_attribute` (`owner`, `attribute`,
|
|
19
|
+
# `:event` or `:cond`); an `owner` is a frozen Hash, `kind` (`:initial`
|
|
20
|
+
# or `:history`) and `id`, the owning state's id;
|
|
21
|
+
# - history and final: `:history_bad_parent` (`id`, `parent_kind`),
|
|
22
|
+
# `:history_bad_type` (`raw`), `:final_has_states` (`id`),
|
|
23
|
+
# `:final_has_transitions` (`id`), `:final_parent_missing_id`
|
|
24
|
+
# (`final_id`), `:default_entry_not_enterable` (`id`, `child_kind`);
|
|
25
|
+
# - enumerated values, read as written: `:transition_bad_type`,
|
|
26
|
+
# `:scxml_bad_binding`, `:scxml_bad_datamodel`,
|
|
27
|
+
# `:invoke_bad_autoforward` (each `raw`);
|
|
28
|
+
# - the root: `:bad_namespace` (`uri`), `:bad_version` (`version`);
|
|
29
|
+
# - data: `:donedata_not_on_final` (`id`), `:donedata_content_and_params`
|
|
30
|
+
# (`id`), `:content_expr_and_text` (`expr`), `:param_expr_and_location`
|
|
31
|
+
# (`name`), `:param_no_value` (`name`), `:data_expr_and_src` (`id`),
|
|
32
|
+
# `:data_value_and_children` (`id`), `:data_reserved_id` (`id`),
|
|
33
|
+
# `:datamodel_bad_parent` (`kind`);
|
|
34
|
+
# - executable content: `:assign_expr_and_text` (`expr`),
|
|
35
|
+
# `:if_elseif_after_else`, `:if_duplicate_else`,
|
|
36
|
+
# `:script_no_src_or_text`, `:invoke_type_and_typeexpr`,
|
|
37
|
+
# `:invoke_src_and_srcexpr`, `:invoke_src_and_content`,
|
|
38
|
+
# `:invoke_id_and_idlocation`, `:invoke_namelist_and_param`,
|
|
39
|
+
# `:send_event_and_eventexpr`, `:send_target_and_targetexpr`,
|
|
40
|
+
# `:send_type_and_typeexpr`, `:send_id_and_idlocation`,
|
|
41
|
+
# `:send_delay_and_delayexpr`, `:send_delay_and_internal_target`,
|
|
42
|
+
# `:send_namelist_and_content`, `:send_param_and_content`,
|
|
43
|
+
# `:cancel_sendid_and_sendidexpr`, `:cancel_no_sendid`;
|
|
44
|
+
# - the markup: `:duplicate_attribute` (`element`, `attribute`).
|
|
45
|
+
#
|
|
46
|
+
# A reason listed without detail carries an empty Hash.
|
|
47
|
+
#
|
|
48
|
+
# @api private
|
|
49
|
+
Error = ::Data.define(:reason, :detail, :message, :location)
|
|
50
|
+
|
|
51
|
+
# A finding that does not gate compilation: the chart is compiled either
|
|
52
|
+
# way, and the engine has a behavior for it. The shape of {Error}. The
|
|
53
|
+
# one reason is `:finalize_forbidden_content` (`element`, `"raise"` or
|
|
54
|
+
# `"send"`): an element the specification forbids inside `<finalize>`.
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
Warning = ::Data.define(:reason, :detail, :message, :location)
|
|
58
|
+
|
|
59
|
+
# The constructors of {Error} and {Warning}, with the reference's
|
|
60
|
+
# messages.
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
module Findings
|
|
64
|
+
SCXML = Lowering::SCXML_NAMESPACE
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# @return [Error] the error `reason` with `detail`, at `location`;
|
|
69
|
+
# `wording` reaches the message and is not kept in the detail
|
|
70
|
+
def error(reason, location, wording: nil, **detail)
|
|
71
|
+
make(Error, Messages::ERROR, [reason, location, wording], detail)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Warning] the warning `reason` with `detail`, at `location`
|
|
75
|
+
def warning(reason, location, **detail) = make(Warning, Messages::WARNING, [reason, location, nil], detail)
|
|
76
|
+
|
|
77
|
+
# A finding with its detail and message frozen.
|
|
78
|
+
def make(type, messages, (reason, location, wording), detail)
|
|
79
|
+
frozen = detail.transform_values { _1.is_a?(Hash) ? _1.dup.freeze : _1 }.freeze
|
|
80
|
+
message = messages.fetch(reason).call(wording.nil? ? frozen : frozen.merge(wording:))
|
|
81
|
+
type.new(reason:, detail: frozen, message: message.freeze, location:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A value as the reference inspects it: a string quoted, nil as `nil`.
|
|
85
|
+
def inspect_value(value) = value.nil? ? "nil" : Lowering::Errors.quote(value)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
require_relative "messages"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Validator
|
|
5
|
+
# The checks of executable content and of `<invoke>`.
|
|
6
|
+
#
|
|
7
|
+
# Each reaches the nodes the reference's own check reaches. Every check
|
|
8
|
+
# reads a state's `<onentry>` and `<onexit>` blocks, its transitions and
|
|
9
|
+
# its `<initial>` element's, descending into `<if>` branches and
|
|
10
|
+
# `<foreach>` bodies; the `<send>` and `<cancel>` checks also read every
|
|
11
|
+
# `<invoke>`'s `<finalize>`, and the `<script>` check the root's
|
|
12
|
+
# scripts. A `<send>`'s `<content>` is not read by the `<content>` check.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
module Executable
|
|
16
|
+
# Whether both attributes were written.
|
|
17
|
+
BOTH = ->(node, name, partner) { !node.public_send(name).nil? && !node.public_send(partner).nil? }
|
|
18
|
+
|
|
19
|
+
# The rules of an `<invoke>`, in the reference's order.
|
|
20
|
+
INVOKE_RULES = {
|
|
21
|
+
invoke_type_and_typeexpr: ->(node) { BOTH.call(node, :type, :typeexpr) },
|
|
22
|
+
invoke_src_and_srcexpr: ->(node) { BOTH.call(node, :src, :srcexpr) },
|
|
23
|
+
invoke_src_and_content: ->(node) { !node.content.nil? && !(node.src || node.srcexpr).nil? },
|
|
24
|
+
invoke_id_and_idlocation: ->(node) { BOTH.call(node, :id, :idlocation) },
|
|
25
|
+
invoke_namelist_and_param: ->(node) { !node.namelist.empty? && !node.params.empty? }
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# The targets a delay may not be written beside.
|
|
29
|
+
INTERNAL = %w[_internal #_internal].freeze
|
|
30
|
+
|
|
31
|
+
# The rules of a `<send>`, in the reference's order. A delay beside an
|
|
32
|
+
# internal target is refused only when the target is written literally:
|
|
33
|
+
# with `targetexpr` it is not known before the send executes.
|
|
34
|
+
SEND_RULES = {
|
|
35
|
+
send_event_and_eventexpr: ->(node) { BOTH.call(node, :event, :eventexpr) },
|
|
36
|
+
send_target_and_targetexpr: ->(node) { BOTH.call(node, :target, :targetexpr) },
|
|
37
|
+
send_type_and_typeexpr: ->(node) { BOTH.call(node, :type, :typeexpr) },
|
|
38
|
+
send_id_and_idlocation: ->(node) { BOTH.call(node, :id, :idlocation) },
|
|
39
|
+
send_delay_and_delayexpr: ->(node) { BOTH.call(node, :delay, :delayexpr) },
|
|
40
|
+
send_delay_and_internal_target: lambda do |node|
|
|
41
|
+
INTERNAL.include?(node.target) && !(node.delay || node.delayexpr).nil?
|
|
42
|
+
end,
|
|
43
|
+
send_namelist_and_content: ->(node) { !node.namelist.empty? && !node.content.nil? },
|
|
44
|
+
send_param_and_content: ->(node) { !node.params.empty? && !node.content.nil? }
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# The element a `<finalize>` may not hold, by node class.
|
|
48
|
+
FORBIDDEN_IN_FINALIZE = { Document::Raise => "raise", Document::Send => "send" }.freeze
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# Every node of a state's blocks and transitions, in document order, a
|
|
53
|
+
# composite before what it holds.
|
|
54
|
+
def state_nodes(state)
|
|
55
|
+
initial = state.initial_element&.transitions || Lowering::Reading::EMPTY
|
|
56
|
+
content = [*state.onentry, *state.onexit, *state.transitions, *initial].flat_map(&:content)
|
|
57
|
+
content.flat_map { descend(_1) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The nodes of every `<finalize>` of a state's `<invoke>` children.
|
|
61
|
+
def finalize_nodes(state) = state.invoke.filter_map(&:finalize).flat_map(&:content).flat_map { descend(_1) }
|
|
62
|
+
|
|
63
|
+
# `node` and every node an `<if>` or `<foreach>` holds below it.
|
|
64
|
+
def descend(node)
|
|
65
|
+
inner = case node
|
|
66
|
+
when Document::If then node.branches.flat_map(&:content)
|
|
67
|
+
when Document::Foreach then node.content
|
|
68
|
+
else Lowering::Reading::EMPTY
|
|
69
|
+
end
|
|
70
|
+
[node, *inner.flat_map { descend(_1) }]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Every `<send>`, its `<finalize>` ones included.
|
|
74
|
+
def sends(context)
|
|
75
|
+
context.states.flat_map { state_nodes(_1) + finalize_nodes(_1) }.grep(Document::Send)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# An `<assign>` does not carry both `expr` and child content, markup or
|
|
79
|
+
# text that is not blank; at the element's span.
|
|
80
|
+
def assigns(_document, context)
|
|
81
|
+
context.states.flat_map { state_nodes(_1) }.grep(Document::Assign).filter_map do |assign|
|
|
82
|
+
next if assign.expr.nil? || (assign.markup.nil? && Lowering::Reading.blank?(assign.text))
|
|
83
|
+
|
|
84
|
+
Findings.error(:assign_expr_and_text, assign.node_location, expr: assign.expr)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Every branch after an `<if>`'s first `<else>` is out of order: another
|
|
89
|
+
# `<else>` is a duplicate, an `<elseif>` follows it.
|
|
90
|
+
def ifs(_document, context)
|
|
91
|
+
context.states.flat_map { state_nodes(_1) }.grep(Document::If).flat_map do |node|
|
|
92
|
+
after_else = node.branches.drop_while { !_1.cond.nil? }.drop(1)
|
|
93
|
+
after_else.map do |branch|
|
|
94
|
+
Findings.error(branch.cond.nil? ? :if_duplicate_else : :if_elseif_after_else, branch.location)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A `<script>` has a program: text that is not blank.
|
|
100
|
+
def scripts(document, context)
|
|
101
|
+
all = context.states.flat_map { state_nodes(_1) }.grep(Document::Script) + document.scripts
|
|
102
|
+
all.select { Lowering::Reading.blank?(_1.text) }.map { Findings.error(:script_no_src_or_text, _1.location) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# An `<invoke>`'s exclusive pairs, each at its span.
|
|
106
|
+
def invokes(_document, context) = broken(context.states.flat_map(&:invoke), INVOKE_RULES)
|
|
107
|
+
|
|
108
|
+
# A `<send>`'s exclusive pairs, each at its span.
|
|
109
|
+
def sends_check(_document, context) = broken(sends(context), SEND_RULES)
|
|
110
|
+
|
|
111
|
+
# A `<cancel>` carries exactly one of `sendid` and `sendidexpr`.
|
|
112
|
+
def cancels(_document, context)
|
|
113
|
+
all = context.states.flat_map { state_nodes(_1) + finalize_nodes(_1) }.grep(Document::Cancel)
|
|
114
|
+
all.filter_map do |cancel|
|
|
115
|
+
written = [cancel.sendid, cancel.sendidexpr].compact.size
|
|
116
|
+
next if written == 1
|
|
117
|
+
|
|
118
|
+
Findings.error(written.zero? ? :cancel_no_sendid : :cancel_sendid_and_sendidexpr, cancel.location)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The error of each rule each node breaks, at the node's span.
|
|
123
|
+
def broken(nodes, rules)
|
|
124
|
+
nodes.flat_map do |node|
|
|
125
|
+
rules.select { |_, rule| rule.call(node) }.keys.map { Findings.error(_1, node.location) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The warning: a `<raise>` or `<send>` inside a `<finalize>`, at its own
|
|
130
|
+
# span.
|
|
131
|
+
def finalize_warnings(_document, context)
|
|
132
|
+
context.states.flat_map { finalize_nodes(_1) }.filter_map do |node|
|
|
133
|
+
element = FORBIDDEN_IN_FINALIZE[node.class]
|
|
134
|
+
Findings.warning(:finalize_forbidden_content, node.location, element:) if element
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Validator
|
|
5
|
+
# The checks of history states, final states and default entry.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module History
|
|
9
|
+
HISTORY_TYPES = %w[shallow deep].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# A history state: its parent is a compound `<state>` or `<parallel>`,
|
|
14
|
+
# it has one default transition, that transition's resolved targets
|
|
15
|
+
# lie inside the parent, and its `type` as written is in range.
|
|
16
|
+
def histories(_document, context)
|
|
17
|
+
context.states.select { _1.kind == :history }.flat_map do |state|
|
|
18
|
+
parent = context.parents.fetch(state)
|
|
19
|
+
compound = Index.compound?(parent)
|
|
20
|
+
bad_parent(state, parent, compound) +
|
|
21
|
+
default_transition(state) + outside_parent(state, parent, compound, context) +
|
|
22
|
+
Enums.out_of_range(state, :type, HISTORY_TYPES, context, :history_bad_type)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A history's own transitions, as its one default transition.
|
|
27
|
+
def default_transition(state)
|
|
28
|
+
States.default_transition({ kind: :history, id: state.id }, state.transitions, state.location)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The resolved default targets outside a compound parent; a parent
|
|
32
|
+
# that is not compound is reported once, as such, and not tested.
|
|
33
|
+
def outside_parent(state, parent, compound, context)
|
|
34
|
+
compound ? state.transitions.flat_map { States.outside_targets(_1, parent, context) } : []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The `:history_bad_parent` error of a history whose parent is not
|
|
38
|
+
# compound; the root's kind is `:scxml`.
|
|
39
|
+
def bad_parent(state, parent, compound)
|
|
40
|
+
return [] if compound
|
|
41
|
+
|
|
42
|
+
parent_kind = parent.is_a?(Document::State) ? parent.kind : :scxml
|
|
43
|
+
[Findings.error(:history_bad_parent, state.location, id: state.id, parent_kind:)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A `<final>` holds no state and no transition: each is reported at its
|
|
47
|
+
# own span.
|
|
48
|
+
def finals(_document, context)
|
|
49
|
+
context.states.select { _1.kind == :final }.flat_map do |state|
|
|
50
|
+
state.states.map { Findings.error(:final_has_states, _1.location, id: _1.id) } +
|
|
51
|
+
state.transitions.map { Findings.error(:final_has_transitions, _1.location, id: state.id) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A state with a `<final>` child has an id, since the completion event
|
|
56
|
+
# is named after it. Reported once per parent, at its span, naming its
|
|
57
|
+
# first `<final>`.
|
|
58
|
+
def final_parents(_document, context)
|
|
59
|
+
context.states.filter_map do |state|
|
|
60
|
+
first = state.states.find { _1.kind == :final }
|
|
61
|
+
next if !state.id.nil? || first.nil?
|
|
62
|
+
|
|
63
|
+
Findings.error(:final_parent_missing_id, state.location, final_id: first.id)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A `<state>` entered by default enters its first child, and a history
|
|
68
|
+
# cannot be entered that way. A `<parallel>` enters every child, so it
|
|
69
|
+
# is not read here.
|
|
70
|
+
def default_entries(_document, context)
|
|
71
|
+
context.states.filter_map do |state|
|
|
72
|
+
first = state.states.first
|
|
73
|
+
next unless state.kind == :state && state.initial.empty? && state.initial_element.nil?
|
|
74
|
+
next unless first&.kind == :history
|
|
75
|
+
|
|
76
|
+
Findings.error(:default_entry_not_enterable, first.location, id: state.id, child_kind: :history)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Validator
|
|
5
|
+
# The message of each reason, made from its detail: the reference's
|
|
6
|
+
# wording, with its quoting of values.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module Messages
|
|
10
|
+
q = Findings.method(:inspect_value)
|
|
11
|
+
owner = lambda do |detail|
|
|
12
|
+
kind, id = detail[:owner].values_at(:kind, :id)
|
|
13
|
+
element = "<#{kind}>"
|
|
14
|
+
next element if id.nil?
|
|
15
|
+
|
|
16
|
+
kind == :initial ? "#{element} on #{q.call(id)}" : "#{element} #{q.call(id)}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The messages of the state, target and default-transition reasons.
|
|
20
|
+
STATES = {
|
|
21
|
+
duplicate_id: ->(d) { "duplicate state id #{q.call(d[:id])}" },
|
|
22
|
+
empty_id: ->(_) { "state id must not be empty - an absent id is legal, an empty one is not" },
|
|
23
|
+
unresolved_target: ->(d) { "transition target #{q.call(d[:id])} does not resolve to a state in the document" },
|
|
24
|
+
unresolved_initial: ->(d) { "initial state #{q.call(d[:id])} does not resolve to a state in the document" },
|
|
25
|
+
initial_not_descendant: lambda do |d|
|
|
26
|
+
parent = d[:parent_id].nil? ? "its parent state, which has no id" : q.call(d[:parent_id])
|
|
27
|
+
"initial state #{q.call(d[:id])} is not a descendant of #{parent}"
|
|
28
|
+
end,
|
|
29
|
+
initial_on_atomic_state: lambda do |d|
|
|
30
|
+
"#{d[:id].nil? ? "a state with no id" : "state #{q.call(d[:id])}"} has no child states to default into"
|
|
31
|
+
end,
|
|
32
|
+
initial_attribute_and_element: lambda do |d|
|
|
33
|
+
"state #{q.call(d[:id])} has both an initial attribute and an <initial> element - at most one is legal"
|
|
34
|
+
end,
|
|
35
|
+
transition_count: ->(d) { "#{owner.call(d)} must have exactly one transition, found #{d[:count]}" },
|
|
36
|
+
transition_missing_target: ->(d) { "#{owner.call(d)} transition must specify a target" },
|
|
37
|
+
transition_forbidden_attribute: ->(d) { "#{owner.call(d)} transition must not specify #{d[:attribute]}" },
|
|
38
|
+
history_bad_parent: lambda do |d|
|
|
39
|
+
"history #{q.call(d[:id])} must be a child of a compound <state> or <parallel>, not #{d[:parent_kind]}"
|
|
40
|
+
end,
|
|
41
|
+
history_bad_type: ->(d) { "history type #{q.call(d[:raw])} must be \"shallow\" or \"deep\"" },
|
|
42
|
+
final_has_states: lambda do |d|
|
|
43
|
+
"state #{q.call(d[:id])} must not be a child of <final> - only onentry, onexit, and donedata are legal there"
|
|
44
|
+
end,
|
|
45
|
+
final_has_transitions: lambda do |d|
|
|
46
|
+
"state #{q.call(d[:id])} is a <final> and must not carry a <transition> - " \
|
|
47
|
+
"only onentry, onexit, and donedata are legal there"
|
|
48
|
+
end,
|
|
49
|
+
final_parent_missing_id: lambda do |_|
|
|
50
|
+
"a state containing a <final> child must have an id - its completion event is named done.state.<id>"
|
|
51
|
+
end,
|
|
52
|
+
default_entry_not_enterable: lambda do |d|
|
|
53
|
+
"state #{q.call(d[:id])} has no initial attribute or <initial> element, and its first child is a " \
|
|
54
|
+
"#{d[:child_kind]} pseudo-state, which cannot be entered by default"
|
|
55
|
+
end
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# The messages of the root, enumerated-value and data reasons.
|
|
59
|
+
DATA = {
|
|
60
|
+
transition_bad_type: ->(d) { "transition type #{q.call(d[:raw])} must be \"internal\" or \"external\"" },
|
|
61
|
+
scxml_bad_binding: ->(d) { "binding #{q.call(d[:raw])} must be \"early\" or \"late\"" },
|
|
62
|
+
scxml_bad_datamodel: lambda do |d|
|
|
63
|
+
"datamodel #{q.call(d[:raw])} must be one of \"predicator\", \"elixir\", \"null\", \"ecmascript\", " \
|
|
64
|
+
"or \"xpath\""
|
|
65
|
+
end,
|
|
66
|
+
invoke_bad_autoforward: ->(d) { "invoke autoforward #{q.call(d[:raw])} must be \"true\" or \"false\"" },
|
|
67
|
+
bad_namespace: lambda do |d|
|
|
68
|
+
next "the root element declares no namespace - expected xmlns=#{q.call(Findings::SCXML)}" if d[:uri].nil?
|
|
69
|
+
|
|
70
|
+
"the root element's namespace #{q.call(d[:uri])} is not the SCXML namespace #{q.call(Findings::SCXML)}"
|
|
71
|
+
end,
|
|
72
|
+
bad_version: lambda do |d|
|
|
73
|
+
next "the root element has no version attribute, expected \"1.0\"" if d[:version].nil?
|
|
74
|
+
|
|
75
|
+
"the root element's version #{q.call(d[:version])} must be \"1.0\""
|
|
76
|
+
end,
|
|
77
|
+
donedata_not_on_final: ->(d) { "state #{q.call(d[:id])} carries <donedata>, which is only legal on <final>" },
|
|
78
|
+
donedata_content_and_params: lambda do |d|
|
|
79
|
+
"<donedata> on #{q.call(d[:id])} must not specify both a <content> child and one or more <param> children"
|
|
80
|
+
end,
|
|
81
|
+
content_expr_and_text: lambda do |d|
|
|
82
|
+
"<content> must not specify both an expr attribute (#{q.call(d[:expr])}) and inline #{d[:wording]}"
|
|
83
|
+
end,
|
|
84
|
+
param_expr_and_location: lambda do |d|
|
|
85
|
+
"<param> #{q.call(d[:name])} must not specify both an expr attribute and a location attribute"
|
|
86
|
+
end,
|
|
87
|
+
param_no_value: lambda do |d|
|
|
88
|
+
"<param> #{q.call(d[:name])} must specify either an expr attribute or a location attribute"
|
|
89
|
+
end,
|
|
90
|
+
data_expr_and_src: ->(d) { "<data> #{q.call(d[:id])} must not specify both expr and src" },
|
|
91
|
+
data_value_and_children: lambda do |d|
|
|
92
|
+
"<data> #{q.call(d[:id])} must not specify expr or src together with child content"
|
|
93
|
+
end,
|
|
94
|
+
data_reserved_id: ->(d) { "<data> id #{q.call(d[:id])} must not begin with \"_\" - that prefix is reserved" },
|
|
95
|
+
datamodel_bad_parent: ->(d) { "<datamodel> must not be a child of a #{d[:kind]} state" }
|
|
96
|
+
}.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The messages, continued.
|
|
100
|
+
#
|
|
101
|
+
# @api private
|
|
102
|
+
module Messages
|
|
103
|
+
q = Findings.method(:inspect_value)
|
|
104
|
+
|
|
105
|
+
# The messages of the executable-content and markup reasons.
|
|
106
|
+
EXECUTABLE = {
|
|
107
|
+
assign_expr_and_text: lambda do |d|
|
|
108
|
+
"<assign> must not specify both an expr attribute (#{q.call(d[:expr])}) and child content"
|
|
109
|
+
end,
|
|
110
|
+
if_elseif_after_else: ->(_) { "<elseif> must not follow <else> - <else> must occur after all <elseif> tags" },
|
|
111
|
+
if_duplicate_else: ->(_) { "<if> must not carry more than one <else> branch" },
|
|
112
|
+
script_no_src_or_text: ->(_) { "<script> must specify either the src attribute or child content" },
|
|
113
|
+
invoke_type_and_typeexpr: ->(_) { "<invoke> must not specify both type and typeexpr" },
|
|
114
|
+
invoke_src_and_srcexpr: ->(_) { "<invoke> must not specify both src and srcexpr" },
|
|
115
|
+
invoke_src_and_content: ->(_) { "<invoke> must not specify src or srcexpr together with a <content> child" },
|
|
116
|
+
invoke_id_and_idlocation: ->(_) { "<invoke> must not specify both id and idlocation" },
|
|
117
|
+
invoke_namelist_and_param: ->(_) { "<invoke> must not specify namelist together with a <param> child" },
|
|
118
|
+
send_event_and_eventexpr: ->(_) { "<send> must not specify both event and eventexpr" },
|
|
119
|
+
send_target_and_targetexpr: ->(_) { "<send> must not specify both target and targetexpr" },
|
|
120
|
+
send_type_and_typeexpr: ->(_) { "<send> must not specify both type and typeexpr" },
|
|
121
|
+
send_id_and_idlocation: ->(_) { "<send> must not specify both id and idlocation" },
|
|
122
|
+
send_delay_and_delayexpr: ->(_) { "<send> must not specify both delay and delayexpr" },
|
|
123
|
+
send_delay_and_internal_target: lambda do |_|
|
|
124
|
+
"<send> must not specify delay or delayexpr when target is \"_internal\""
|
|
125
|
+
end,
|
|
126
|
+
send_namelist_and_content: ->(_) { "<send> must not specify namelist together with a <content> child" },
|
|
127
|
+
send_param_and_content: ->(_) { "<send> must not specify a <param> child together with a <content> child" },
|
|
128
|
+
cancel_sendid_and_sendidexpr: ->(_) { "<cancel> must not specify both sendid and sendidexpr" },
|
|
129
|
+
cancel_no_sendid: ->(_) { "<cancel> must specify exactly one of sendid or sendidexpr" },
|
|
130
|
+
duplicate_attribute: ->(d) { "duplicate attribute #{q.call(d[:attribute])} on <#{d[:element]}>" }
|
|
131
|
+
}.freeze
|
|
132
|
+
|
|
133
|
+
# Every error reason's message.
|
|
134
|
+
ERROR = STATES.merge(DATA, EXECUTABLE).freeze
|
|
135
|
+
|
|
136
|
+
# Every warning reason's message.
|
|
137
|
+
WARNING = {
|
|
138
|
+
finalize_forbidden_content: ->(d) { "<#{d[:element]}> must not occur inside <finalize>" }
|
|
139
|
+
}.freeze
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module Validator
|
|
5
|
+
# The checks of ids, targets and initial references. Each check takes
|
|
6
|
+
# the chart and the {Context} and answers its errors.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module States
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Every id-typed attribute, a state's and a `<data>`'s, shares one
|
|
13
|
+
# uniqueness set. An empty id is `:empty_id` and joins no set; the
|
|
14
|
+
# first occurrence of an id in document order is canonical and each
|
|
15
|
+
# later one is `:duplicate_id`, at its own span.
|
|
16
|
+
def ids(document, _context)
|
|
17
|
+
entries = in_order(data_ids(document.datamodel_element) + state_ids(document.states))
|
|
18
|
+
empty, named = entries.reject { _1.first.nil? }.partition { _1.first == "" }
|
|
19
|
+
empty.map { Findings.error(:empty_id, _1.last) } + duplicates(named)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Each later entry of an id, the first being canonical.
|
|
23
|
+
def duplicates(entries)
|
|
24
|
+
entries.group_by(&:first).flat_map do |id, (_first, *later)|
|
|
25
|
+
later.map { Findings.error(:duplicate_id, _1.last, id:) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Id entries sorted by where their span starts, found order kept.
|
|
30
|
+
def in_order(entries)
|
|
31
|
+
entries.each_with_index.sort_by { |(_, location), index| [location.start_offset, index] }.map(&:first)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The id entries of states, their datamodels' `<data>` included, in
|
|
35
|
+
# tree order.
|
|
36
|
+
def state_ids(states)
|
|
37
|
+
states.flat_map do |state|
|
|
38
|
+
[[state.id, span(state, :id)], *data_ids(state.datamodel_element), *state_ids(state.states)]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The id entries of a `<datamodel>`'s `<data>`.
|
|
43
|
+
def data_ids(datamodel) = datamodel.nil? ? [] : datamodel.data.map { [_1.id, span(_1, :id)] }
|
|
44
|
+
|
|
45
|
+
# Every transition's targets resolve. This check owns existence: the
|
|
46
|
+
# checks after it skip an unresolved id rather than report it again.
|
|
47
|
+
def targets(_document, context)
|
|
48
|
+
context.transitions.flat_map do |transition, _owner|
|
|
49
|
+
location = span(transition, :target)
|
|
50
|
+
transition.target.reject { context.by_id.key?(_1) }.map do |id|
|
|
51
|
+
Findings.error(:unresolved_target, location, id:)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Every `initial` reference resolves and lands inside the state it
|
|
57
|
+
# initializes; a state with nothing to default into is reported once
|
|
58
|
+
# and nothing else is checked on it. The chart's own `initial` need
|
|
59
|
+
# only resolve.
|
|
60
|
+
def initial_targets(document, context)
|
|
61
|
+
location = span(document, :initial)
|
|
62
|
+
root = document.initial.reject { context.by_id.key?(_1) }.map do |id|
|
|
63
|
+
Findings.error(:unresolved_initial, location, id:)
|
|
64
|
+
end
|
|
65
|
+
context.states.flat_map { initial_state(_1, context) } + root
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The initial-target errors of one state.
|
|
69
|
+
def initial_state(state, context)
|
|
70
|
+
return [] if state.initial.empty? && state.initial_element.nil?
|
|
71
|
+
|
|
72
|
+
return [atomic(state)] if state.states.empty? || %i[parallel final history].include?(state.kind)
|
|
73
|
+
|
|
74
|
+
initial_attribute(state, context) + initial_element_targets(state, context)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The `:initial_on_atomic_state` error of a state with nothing to
|
|
78
|
+
# default into: at its `initial`, else at its `<initial>`.
|
|
79
|
+
def atomic(state)
|
|
80
|
+
location = state.initial.empty? ? state.initial_element.location : span(state, :initial)
|
|
81
|
+
Findings.error(:initial_on_atomic_state, location, id: state.id)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The ids of a state's `initial` attribute.
|
|
85
|
+
def initial_attribute(state, context)
|
|
86
|
+
location = span(state, :initial)
|
|
87
|
+
state.initial.filter_map do |id|
|
|
88
|
+
if !context.by_id.key?(id)
|
|
89
|
+
Findings.error(:unresolved_initial, location, id:)
|
|
90
|
+
elsif outside?(id, state, context)
|
|
91
|
+
Findings.error(:initial_not_descendant, location, id:, parent_id: state.id)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The resolved targets of a state's `<initial>` that lie outside it.
|
|
97
|
+
def initial_element_targets(state, context)
|
|
98
|
+
transitions = state.initial_element&.transitions || Lowering::Reading::EMPTY
|
|
99
|
+
transitions.flat_map { outside_targets(_1, state, context) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The `:initial_not_descendant` errors of a transition's resolved
|
|
103
|
+
# targets outside `parent`.
|
|
104
|
+
def outside_targets(transition, parent, context)
|
|
105
|
+
location = span(transition, :target)
|
|
106
|
+
transition.target.select { context.by_id.key?(_1) && outside?(_1, parent, context) }.map do |id|
|
|
107
|
+
Findings.error(:initial_not_descendant, location, id:, parent_id: parent.id)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether the state `id` names lies outside `state`, by the tree.
|
|
112
|
+
def outside?(id, state, context) = !context.inside?(context.by_id.fetch(id), state)
|
|
113
|
+
|
|
114
|
+
# A state's `<initial>` element: not beside an `initial` attribute,
|
|
115
|
+
# and one default transition.
|
|
116
|
+
def initial_elements(_document, context)
|
|
117
|
+
context.states.reject { _1.initial_element.nil? }.flat_map do |state|
|
|
118
|
+
element = state.initial_element
|
|
119
|
+
owner = { kind: :initial, id: state.id }
|
|
120
|
+
both_forms(state) + default_transition(owner, element.transitions, element.location)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The `:initial_attribute_and_element` error of a state with both.
|
|
125
|
+
def both_forms(state)
|
|
126
|
+
return [] if state.initial.empty?
|
|
127
|
+
|
|
128
|
+
[Findings.error(:initial_attribute_and_element, state.initial_element.location, id: state.id)]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The content model an `<initial>` and a history share: exactly one
|
|
132
|
+
# transition, with a target, and neither `event` nor `cond`. A wrong
|
|
133
|
+
# count is reported at the owner.
|
|
134
|
+
def default_transition(owner, transitions, owner_location)
|
|
135
|
+
count = transitions.size == 1 ? [] : [transitions.size]
|
|
136
|
+
count.map { Findings.error(:transition_count, owner_location, owner:, count: _1) } +
|
|
137
|
+
transitions.flat_map { missing_target(owner, _1) + forbidden(owner, _1) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The `:transition_missing_target` error of a default transition with
|
|
141
|
+
# no target.
|
|
142
|
+
def missing_target(owner, transition)
|
|
143
|
+
return [] unless transition.target.empty?
|
|
144
|
+
|
|
145
|
+
[Findings.error(:transition_missing_target, transition.location, owner:)]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A default transition's written `event` and `cond`: an empty `event`
|
|
149
|
+
# lowers to an empty list, so only its span says it was written.
|
|
150
|
+
def forbidden(owner, transition)
|
|
151
|
+
written = { event: transition.attribute_locations.key?(:event), cond: !transition.cond.nil? }
|
|
152
|
+
written.select { |_, yes| yes }.keys.map do |attribute|
|
|
153
|
+
Findings.error(:transition_forbidden_attribute, span(transition, attribute), owner:, attribute:)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The value span of an attribute when it was written, else the node's.
|
|
158
|
+
def span(node, key) = node.attribute_locations.fetch(key, node.location)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|