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,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"