statifier 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +601 -16
  5. data/lib/statifier/accepts.rb +167 -0
  6. data/lib/statifier/basichttp/decode.rb +142 -0
  7. data/lib/statifier/basichttp/encoding.rb +224 -0
  8. data/lib/statifier/basichttp/net_http_transport.rb +157 -0
  9. data/lib/statifier/basichttp/outbound.rb +87 -0
  10. data/lib/statifier/basichttp/processor.rb +170 -0
  11. data/lib/statifier/basichttp/request.rb +94 -0
  12. data/lib/statifier/basichttp/response.rb +34 -0
  13. data/lib/statifier/basichttp.rb +47 -0
  14. data/lib/statifier/compiler/content.rb +131 -0
  15. data/lib/statifier/compiler/error.rb +30 -0
  16. data/lib/statifier/compiler/expressions.rb +64 -0
  17. data/lib/statifier/compiler/numbering.rb +145 -0
  18. data/lib/statifier/compiler/parts.rb +144 -0
  19. data/lib/statifier/compiler/walk.rb +61 -0
  20. data/lib/statifier/compiler.rb +82 -0
  21. data/lib/statifier/datamodel/data_binding.rb +114 -0
  22. data/lib/statifier/datamodel/delay.rb +54 -0
  23. data/lib/statifier/datamodel/location.rb +145 -0
  24. data/lib/statifier/datamodel/program.rb +82 -0
  25. data/lib/statifier/datamodel/reason_value.rb +42 -0
  26. data/lib/statifier/datamodel/reasons.rb +88 -0
  27. data/lib/statifier/datamodel/stores.rb +169 -0
  28. data/lib/statifier/datamodel/system_variables.rb +69 -0
  29. data/lib/statifier/datamodel.rb +120 -0
  30. data/lib/statifier/document/data.rb +36 -0
  31. data/lib/statifier/document/executable.rb +69 -0
  32. data/lib/statifier/document/invoke.rb +17 -0
  33. data/lib/statifier/document/states.rb +40 -0
  34. data/lib/statifier/document.rb +51 -0
  35. data/lib/statifier/driver/calls.rb +183 -0
  36. data/lib/statifier/driver/checks.rb +110 -0
  37. data/lib/statifier/driver/delivery.rb +45 -0
  38. data/lib/statifier/driver/invocations.rb +129 -0
  39. data/lib/statifier/driver/mailbox.rb +120 -0
  40. data/lib/statifier/driver/routing.rb +193 -0
  41. data/lib/statifier/driver/session_id.rb +40 -0
  42. data/lib/statifier/driver/work.rb +196 -0
  43. data/lib/statifier/driver.rb +205 -0
  44. data/lib/statifier/effect.rb +208 -0
  45. data/lib/statifier/event.rb +67 -0
  46. data/lib/statifier/frozen.rb +22 -0
  47. data/lib/statifier/interpreter/blocks.rb +142 -0
  48. data/lib/statifier/interpreter/content.rb +228 -0
  49. data/lib/statifier/interpreter/entering.rb +141 -0
  50. data/lib/statifier/interpreter/exit_entry.rb +174 -0
  51. data/lib/statifier/interpreter/foreach.rb +83 -0
  52. data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
  53. data/lib/statifier/interpreter/invoke/passes.rb +143 -0
  54. data/lib/statifier/interpreter/invoke.rb +145 -0
  55. data/lib/statifier/interpreter/loop.rb +170 -0
  56. data/lib/statifier/interpreter/machine_state.rb +152 -0
  57. data/lib/statifier/interpreter/macrostep.rb +185 -0
  58. data/lib/statifier/interpreter/name_match.rb +43 -0
  59. data/lib/statifier/interpreter/selection.rb +221 -0
  60. data/lib/statifier/interpreter/send.rb +223 -0
  61. data/lib/statifier/interpreter/step.rb +138 -0
  62. data/lib/statifier/interpreter/stop.rb +66 -0
  63. data/lib/statifier/interpreter/targets.rb +107 -0
  64. data/lib/statifier/interpreter.rb +57 -0
  65. data/lib/statifier/lowering/branches.rb +84 -0
  66. data/lib/statifier/lowering/data.rb +102 -0
  67. data/lib/statifier/lowering/error.rb +102 -0
  68. data/lib/statifier/lowering/executable.rb +106 -0
  69. data/lib/statifier/lowering/placement.rb +65 -0
  70. data/lib/statifier/lowering/reading.rb +53 -0
  71. data/lib/statifier/lowering/states.rb +111 -0
  72. data/lib/statifier/lowering.rb +129 -0
  73. data/lib/statifier/machine/content.rb +81 -0
  74. data/lib/statifier/machine/data.rb +42 -0
  75. data/lib/statifier/machine/states.rb +56 -0
  76. data/lib/statifier/machine/values.rb +37 -0
  77. data/lib/statifier/machine.rb +103 -0
  78. data/lib/statifier/position/rebuild.rb +84 -0
  79. data/lib/statifier/position/shape.rb +73 -0
  80. data/lib/statifier/position/text.rb +43 -0
  81. data/lib/statifier/position.rb +206 -0
  82. data/lib/statifier/validator/attributes.rb +35 -0
  83. data/lib/statifier/validator/context.rb +74 -0
  84. data/lib/statifier/validator/data.rb +136 -0
  85. data/lib/statifier/validator/error.rb +90 -0
  86. data/lib/statifier/validator/executable.rb +139 -0
  87. data/lib/statifier/validator/history.rb +81 -0
  88. data/lib/statifier/validator/messages.rb +142 -0
  89. data/lib/statifier/validator/states.rb +161 -0
  90. data/lib/statifier/validator.rb +94 -0
  91. data/lib/statifier/version.rb +4 -1
  92. data/lib/statifier/xml/character_data.rb +142 -0
  93. data/lib/statifier/xml/cursor.rb +131 -0
  94. data/lib/statifier/xml/markup.rb +153 -0
  95. data/lib/statifier/xml/nodes.rb +50 -0
  96. data/lib/statifier/xml/parser.rb +121 -0
  97. data/lib/statifier/xml/scope.rb +46 -0
  98. data/lib/statifier/xml/tags.rb +112 -0
  99. data/lib/statifier/xml.rb +85 -0
  100. data/lib/statifier.rb +386 -2
  101. metadata +129 -20
  102. data/.gitignore +0 -8
  103. data/CODE_OF_CONDUCT.md +0 -74
  104. data/Gemfile +0 -11
  105. data/Gemfile.lock +0 -33
  106. data/Rakefile +0 -10
  107. data/bin/console +0 -8
  108. data/bin/setup +0 -6
  109. data/statifier.gemspec +0 -34
@@ -0,0 +1,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