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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "machine"
4
+ require_relative "interpreter"
5
+
6
+ module Statifier
7
+ # What {Statifier.check_accepts} answers: the declared names no descriptor
8
+ # in the chart's event vocabulary matches, and the vocabulary's descriptors
9
+ # that match no declared name. A frozen value, deeply.
10
+ #
11
+ # @!attribute [r] unreachable
12
+ # @return [Array<String>] each declared name no descriptor in the
13
+ # vocabulary matches, in the declaration's order and without
14
+ # duplicates: a name the declaration promises and the chart can never
15
+ # select on
16
+ # @!attribute [r] undeclared
17
+ # @return [Array<String>] each descriptor in the vocabulary that matches
18
+ # no declared name, in the vocabulary's order: a name the chart listens
19
+ # for that the declaration does not state
20
+ AcceptsCheck = ::Data.define(:unreachable, :undeclared)
21
+
22
+ # The accepts check: a declaration of the event names a chart accepts,
23
+ # compared with the chart's event vocabulary, as the reference compares
24
+ # them (`Statifier.Chart.check_accepts/2` and the vocabulary it reads,
25
+ # `Statifier.Chart.events/1`, in statifier-ex). {Statifier.check_accepts}
26
+ # answers through this module.
27
+ #
28
+ # The vocabulary is every event descriptor on a transition whose source
29
+ # state can be active, each returned as authored and a pattern never
30
+ # expanded. "Can be active" is a static rule over the chart's structure: a
31
+ # state some path enters, its ancestors included, following Appendix D's
32
+ # `addDescendantStatesToEnter` and `addAncestorStatesToEnter`. The root is
33
+ # entered by its default. Entering a state by its default enters its
34
+ # `initial` states as targets (a compound state or the root), every child
35
+ # that is not a history by its default (a parallel state), or its history
36
+ # default transition's targets as targets (a history pseudo-state).
37
+ # Entering a state as a target enters it by its default, enters each of its
38
+ # proper ancestors, and, for each parallel ancestor, enters by its default
39
+ # every child region that holds none of the transition's targets. Every
40
+ # transition of an entered state enters its targets as targets. No `cond`
41
+ # and no `event` is read, so the rule over-counts and never under-counts.
42
+ # A history pseudo-state contributes no descriptor. Descriptors come in
43
+ # `t_index` order (a state's own transitions before its children's, as the
44
+ # compiler numbers them), within one `event` attribute in the order
45
+ # written, and a descriptor equal as a string to one already listed is
46
+ # dropped.
47
+ #
48
+ # Matching is transition selection's own, {Interpreter::NameMatch}.
49
+ #
50
+ # @api private
51
+ module Accepts
52
+ module_function
53
+
54
+ # The accepts check of `declared` against `machine`'s vocabulary.
55
+ #
56
+ # @param machine [Machine] a compiled chart
57
+ # @param declared [Array<String>, nil] the declared event names, or nil
58
+ # for no declaration
59
+ # @return [AcceptsCheck]
60
+ def check(machine, declared)
61
+ return AcceptsCheck.new(unreachable: [].freeze, undeclared: [].freeze) if declared.nil?
62
+
63
+ descriptors = tokenized(vocabulary(machine))
64
+ names = tokenized(declared.uniq.map(&:-@))
65
+ AcceptsCheck.new(unreachable: unreachable(names, descriptors), undeclared: undeclared(names, descriptors))
66
+ end
67
+
68
+ # Each of `strings` with its tokens, as `[string, tokens]`.
69
+ def tokenized(strings) = strings.map { [_1, Interpreter::NameMatch.tokenize(_1)] }
70
+
71
+ # The declared names no descriptor matches, frozen.
72
+ def unreachable(names, descriptors)
73
+ descriptor_tokens = descriptors.map(&:last)
74
+ names.reject { Interpreter::NameMatch.name_match?(descriptor_tokens, _2) }.map(&:first).freeze
75
+ end
76
+
77
+ # The descriptors that match no declared name, frozen.
78
+ def undeclared(names, descriptors)
79
+ descriptors.reject { |_, tokens| names.any? { Interpreter::NameMatch.name_match?([tokens], _2) } }
80
+ .map(&:first).freeze
81
+ end
82
+
83
+ # The chart's event vocabulary (the reference's `events/1`): every
84
+ # descriptor on a transition of a state that can be active, history
85
+ # pseudo-states left out, in `t_index` order and without duplicates.
86
+ #
87
+ # @param machine [Machine] a compiled chart
88
+ # @return [Array<String>] frozen descriptors
89
+ def vocabulary(machine)
90
+ entered_states(machine)
91
+ .reject { machine.history?(_1) }
92
+ .flat_map { machine.state(_1).transitions }
93
+ .sort
94
+ .flat_map { |t_index| machine.transitions.fetch(t_index).events.map { -_1.join(".") } }
95
+ .uniq
96
+ end
97
+
98
+ # The least set of state indexes closed under the entry rule, as a
99
+ # worklist: `[:default, index]` enters a state by its default and
100
+ # `[:targets, indexes]` enters one transition's (or one `initial`'s)
101
+ # targets as targets. `defaulted` bounds the default expansions and
102
+ # `entered` bounds the per-state transition walk, so each state's work is
103
+ # queued once and the walk ends on any chart, cycles included.
104
+ def entered_states(machine)
105
+ entered = Set.new
106
+ defaulted = Set.new
107
+ work = [[:default, 0]]
108
+ work.concat(entry_work(machine, work.pop, entered, defaulted)) until work.empty?
109
+ entered.to_a
110
+ end
111
+
112
+ # The work one entry adds: a state entered by its default (once), or one
113
+ # list of targets entered as targets.
114
+ def entry_work(machine, (kind, subject), entered, defaulted)
115
+ return enter_targets(machine, subject, entered) if kind == :targets
116
+ return [] unless defaulted.add?(subject)
117
+
118
+ mark_entered(machine, subject, entered) + default_entry(machine, subject)
119
+ end
120
+
121
+ # The work entering `targets` as targets adds: each target by its
122
+ # default, and each proper ancestor entered, with its own transitions'
123
+ # work and, for a parallel ancestor, its untargeted regions by default.
124
+ def enter_targets(machine, targets, entered)
125
+ ancestors = targets.flat_map { machine.proper_ancestors(_1) }.uniq
126
+ ancestor_work = ancestors.flat_map do |ancestor|
127
+ mark_entered(machine, ancestor, entered) + untargeted_regions(machine, ancestor, targets)
128
+ end
129
+ targets.map { [:default, _1] } + ancestor_work
130
+ end
131
+
132
+ # What entering `index` by its default enters next, by kind: a parallel
133
+ # state's regions by their defaults, a history's default transition's
134
+ # targets as targets, and otherwise the compiler-resolved `initial` as
135
+ # targets - nothing on an atomic state.
136
+ def default_entry(machine, index)
137
+ state = machine.state(index)
138
+ case state.kind
139
+ when :parallel then machine.child_states(index).map { [:default, _1] }
140
+ when :history
141
+ state.history_default.nil? ? [] : [[:targets, machine.transitions.fetch(state.history_default).targets]]
142
+ else state.initial.empty? ? [] : [[:targets, state.initial]]
143
+ end
144
+ end
145
+
146
+ # A parallel ancestor's regions that hold none of `targets`, each to be
147
+ # entered by its default; nothing for any other ancestor.
148
+ def untargeted_regions(machine, ancestor, targets)
149
+ return [] unless machine.parallel?(ancestor)
150
+
151
+ machine.child_states(ancestor)
152
+ .reject { |region| targets.any? { _1 == region || machine.descendant?(_1, region) } }
153
+ .map { [:default, _1] }
154
+ end
155
+
156
+ # Marks `index` entered. The first time only, answers the work its own
157
+ # transitions add: each targeted transition's targets, as targets.
158
+ def mark_entered(machine, index, entered)
159
+ return [] unless entered.add?(index)
160
+
161
+ machine.state(index).transitions
162
+ .map { machine.transitions.fetch(_1).targets }
163
+ .reject(&:empty?)
164
+ .map { [:targets, _1] }
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ # The receive side of the Basic HTTP processor: {BasicHTTP.decode} and
5
+ # its {BasicHTTP::Refusal}.
6
+ module BasicHTTP
7
+ # Why {BasicHTTP.decode} formed no event from a request.
8
+ #
9
+ # @!attribute [r] reason
10
+ # @return [Symbol] `:method_not_allowed` (the method is not POST; a
11
+ # front answers 405 with `Allow: POST`), `:not_utf8` (the query
12
+ # string or the body does not decode to UTF-8) or
13
+ # `:malformed_send_key` (the `scxml-send-key` value is not eight
14
+ # fields joined by `/` whose second decodes to UTF-8); a front
15
+ # answers either of the last two 400
16
+ # @!attribute [r] detail
17
+ # @return [String, Symbol] the method, `:query` or `:body`, or the
18
+ # header's value
19
+ Refusal = ::Data.define(:reason, :detail)
20
+
21
+ # The event one HTTP request carries (appendix C.2.1), as the
22
+ # reference's `Statifier.Send.BasicHTTP.decode/1` forms it. It is pure
23
+ # and knows no session: a host's own server resolves the request's
24
+ # location to a session, decodes the request here and passes the event's
25
+ # `name` and `data` to {Statifier.step} with the state it holds for that
26
+ # session.
27
+ #
28
+ # - The method must be POST, in any case.
29
+ # - The event's name is the first `_scxmleventname` parameter found, the
30
+ # query string's before a form body's, else `HTTP.` and the method in
31
+ # upper case (`HTTP.POST`).
32
+ # - A form body (a content type that begins
33
+ # `application/x-www-form-urlencoded`, in any case) and the query
34
+ # string give the other parameters, which are the event's data: a
35
+ # Hash whose values are each read as a `<content>` body's text is read
36
+ # (a predicator literal, else the text with its whitespace collapsed),
37
+ # so `2` is the number 2; the last of a repeated name wins, and no
38
+ # parameters is no data (`Predicator::UNDEFINED`).
39
+ # - A body of any other content type, a JSON body included, is the data,
40
+ # read the same way, and the query string then gives the name only. No
41
+ # charset is read.
42
+ # - The event is external, its `origintype` {EVENT_PROCESSOR} and its
43
+ # `origin` and `sendid` nil.
44
+ # - The `scxml-send-key` value is checked and sets nothing on the event:
45
+ # deduplicating on it is the server's. A server that has already taken
46
+ # a request with the same value answers 204 again and takes nothing.
47
+ #
48
+ # A server answers 204 once it has taken the event, 405 or 400 for a
49
+ # refusal, and its own 404 for a location that names no session it holds.
50
+ #
51
+ # @example
52
+ # decoded = Statifier::BasicHTTP.decode(method: "POST", content_type: "application/x-www-form-urlencoded",
53
+ # body: "_scxmleventname=e.go&n=2", query: nil)
54
+ # decoded.value.name # => "e.go"
55
+ # decoded.value.data # => {"n" => 2}
56
+ #
57
+ # @param method [String] the request's method
58
+ # @param content_type [String, nil] its content type, nil when it carries
59
+ # none
60
+ # @param body [String] its body, empty for none
61
+ # @param query [String, nil] its query string without the `?`, nil when
62
+ # the URL has none
63
+ # @param send_key [String, nil] its `scxml-send-key` header's value, nil
64
+ # when it carries none
65
+ # @return [Statifier::Result] the {Statifier::Event}, deeply frozen, or a
66
+ # {Refusal}
67
+ # @raise [TypeError] for an argument of the wrong kind
68
+ def self.decode(method:, content_type:, body:, query:, send_key: nil)
69
+ Decode.request(method, content_type, body, query, send_key)
70
+ end
71
+
72
+ # The body of {BasicHTTP.decode}.
73
+ #
74
+ # @api private
75
+ module Decode
76
+ module_function
77
+
78
+ # {BasicHTTP.decode}'s body, its checks in the reference's order: the
79
+ # method, the query string, the body, the send key.
80
+ def request(method, content_type, body, query, send_key)
81
+ checked(method:, body:, content_type:, query:, send_key:)
82
+ return refused(:method_not_allowed, method) unless method.upcase == "POST"
83
+
84
+ query_pairs = query.nil? ? [] : Encoding.pairs(query)
85
+ return refused(:not_utf8, :query) if query_pairs.nil?
86
+
87
+ decoded(method, query_pairs, body(content_type, body), send_key)
88
+ end
89
+
90
+ # The event, after the body and the send key are checked.
91
+ def decoded(method, query_pairs, (body_pairs, text), send_key)
92
+ return refused(:not_utf8, :body) if body_pairs.nil?
93
+ return refused(:malformed_send_key, send_key) unless send_key.nil? || send_key?(send_key)
94
+
95
+ Result.new(value: event(method, query_pairs + body_pairs, text), error: nil)
96
+ end
97
+
98
+ # A form body's pairs and no text, or no pairs and the body as text;
99
+ # nil pairs when it does not decode to UTF-8.
100
+ def body(content_type, body)
101
+ return [Encoding.pairs(body), nil] if content_type&.downcase&.start_with?(Outbound::FORM)
102
+
103
+ text = Encoding.utf8(body.b)
104
+ text.valid_encoding? ? [[], text] : [nil, nil]
105
+ end
106
+
107
+ # Whether `value` is eight fields joined by `/` whose second decodes to
108
+ # UTF-8.
109
+ def send_key?(value)
110
+ fields = value.split("/", -1)
111
+ fields.size == 8 && !Encoding.unfield(fields[1]).nil?
112
+ end
113
+
114
+ # The event the pairs and the text carry.
115
+ def event(method, pairs, text)
116
+ named, params = pairs.partition { _1.first == Outbound::EVENT_NAME_PARAM }
117
+ name = named.empty? ? "HTTP.#{method.upcase}" : named.first.last
118
+ data = text ? Interpreter::Send.text_data(text) : params_data(params)
119
+ Frozen.copy(Event.external(name, data:, origintype: EVENT_PROCESSOR))
120
+ end
121
+
122
+ # Each parameter's value read as text, the last of a name winning.
123
+ def params_data(params)
124
+ Interpreter::Send.params_data(params.map { |name, value| [name, Interpreter::Send.text_data(value)] })
125
+ end
126
+
127
+ def refused(reason, detail) = Result.new(value: nil, error: Refusal.new(reason:, detail: frozen(detail)))
128
+
129
+ def frozen(detail) = detail.is_a?(String) ? detail.dup.freeze : detail
130
+
131
+ # The arguments, after checking their kinds.
132
+ def checked(**arguments)
133
+ arguments.each do |name, value|
134
+ next if value.is_a?(String) || (value.nil? && !%i[method body].include?(name))
135
+
136
+ raise TypeError, "expected #{name} to be a String#{" or nil" unless %i[method body].include?(name)}, " \
137
+ "got #{value.class}"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module BasicHTTP
5
+ # The Basic HTTP processor's text encodings, as the reference writes and
6
+ # reads them (`Statifier.Send.BasicHTTP` in statifier-ex): a form body
7
+ # and a query string (its `URI.encode_query(pairs, :www_form)` and
8
+ # `URI.query_decoder(text, :www_form)`), the two escaped fields of the
9
+ # `scxml-send-key` header (`URI.encode/2` keeping RFC 3986's unreserved
10
+ # set, and `URI.decode/1`), and a parameter value written as text (its
11
+ # private `encode/1` and `json/1`).
12
+ #
13
+ # Every encoding works on a string's bytes, so a value that is not UTF-8
14
+ # is escaped byte for byte, as the reference escapes one.
15
+ #
16
+ # @api private
17
+ module Encoding
18
+ module_function
19
+
20
+ # The bytes neither encoding escapes: RFC 3986's unreserved set.
21
+ ESCAPED = /[^A-Za-z0-9\-._~]/n
22
+
23
+ # An escape the decoders read: `%` and two hex digits.
24
+ ESCAPE = /%(\h\h)/n
25
+
26
+ # The deepest a list or a map is written as JSON text; one nested
27
+ # deeper is written as predicator's tagged-value text instead, whose
28
+ # encoder refuses past its own limit.
29
+ JSON_DEPTH = 256
30
+
31
+ # The magnitude from which a float is always written with an exponent,
32
+ # as the reference's runtime writes it: 2 to the 53rd.
33
+ EXPONENT_FROM = 2**53
34
+
35
+ # The JSON escapes of the characters with a short one.
36
+ SHORT_ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\t" => "\\t", "\n" => "\\n", "\f" => "\\f",
37
+ "\r" => "\\r" }.freeze
38
+
39
+ # Name and value pairs as an `application/x-www-form-urlencoded` body,
40
+ # in the order given: every byte outside the unreserved set escaped as
41
+ # `%` and two upper-case hex digits, a space written `+`.
42
+ #
43
+ # @param pairs [Array<Array(String, String)>]
44
+ # @return [String]
45
+ def form(pairs) = pairs.map { |name, value| "#{www_form(name)}=#{www_form(value)}" }.join("&")
46
+
47
+ # One name or value of a form: escaped, a space as `+`.
48
+ def www_form(text) = text.b.gsub(ESCAPED) { _1 == " " ? "+" : format("%%%02X", _1.ord) }.force_encoding("UTF-8")
49
+
50
+ # A `scxml-send-key` field: every byte outside the unreserved set
51
+ # escaped, a space as `%20`, so the field carries no `/`.
52
+ #
53
+ # @param text [String]
54
+ # @return [String]
55
+ def field(text) = text.b.gsub(ESCAPED) { format("%%%02X", _1.ord) }.force_encoding("UTF-8")
56
+
57
+ # A form body or a query string as its name and value pairs, in order,
58
+ # or nil when a name or a value does not decode to UTF-8. Pairs are
59
+ # split at each `&` and a pair at its first `=`; a pair with no `=` has
60
+ # the empty value, an empty pair is two empty strings, and nothing
61
+ # after a final `&` is a pair.
62
+ #
63
+ # @param text [String]
64
+ # @return [Array<Array(String, String)>, nil]
65
+ def pairs(text)
66
+ out = []
67
+ rest = text.b
68
+ until rest.empty?
69
+ pair, _, rest = rest.partition("&")
70
+ name, _, value = pair.partition("=")
71
+ out << [decoded(name, plus: true), decoded(value, plus: true)]
72
+ end
73
+ out.flatten.all? ? out : nil
74
+ end
75
+
76
+ # A field escaped as {field} escapes one, decoded, or nil when it does
77
+ # not decode to UTF-8. A `+` stays a `+`.
78
+ def unfield(text) = decoded(text.b, plus: false)
79
+
80
+ # Text with each escape decoded to its byte (an escape that is not `%`
81
+ # and two hex digits stays as it is written) and, when `plus`, each
82
+ # `+` read as a space; nil when the bytes are not UTF-8.
83
+ def decoded(bytes, plus:)
84
+ bytes = bytes.tr("+", " ") if plus
85
+ text = bytes.gsub(ESCAPE) { Regexp.last_match(1).hex.chr }.force_encoding("UTF-8")
86
+ text.valid_encoding? ? text : nil
87
+ end
88
+
89
+ # A value written as text: a string as it is, nil as `null`,
90
+ # `Predicator::UNDEFINED` as the empty string, a boolean as its
91
+ # literal, an Integer in decimal, a finite Float as {float} writes it,
92
+ # a list or a map as JSON text ({json}) when every value inside it has
93
+ # a JSON form. Anything else (a date, a datetime, a duration, a list
94
+ # or a map holding a value with no JSON form) is predicator's
95
+ # tagged-value text, or the empty string when that encoder refuses it.
96
+ #
97
+ # @param value [Object] a predicator value
98
+ # @return [String]
99
+ def value(value)
100
+ case value
101
+ when String then value
102
+ when nil then "null"
103
+ when true, false, Integer then value.to_s
104
+ when Float then value.finite? ? float(value) : tagged(value)
105
+ else scalar_or_tagged(value)
106
+ end
107
+ end
108
+
109
+ # What {value} writes for a value that is not a String, nil, a
110
+ # boolean or a number.
111
+ def scalar_or_tagged(value)
112
+ return "" if value.equal?(Predicator::UNDEFINED)
113
+
114
+ (json(value) if value.is_a?(Array) || value.is_a?(Hash)) || tagged(value)
115
+ end
116
+
117
+ # Predicator's tagged-value text of `value`, or the empty string.
118
+ def tagged(value)
119
+ encoded = Predicator::Tagged.encode(value)
120
+ encoded.ok? ? encoded.value : ""
121
+ end
122
+
123
+ # A list or a map as JSON text, or nil when a value inside has no JSON
124
+ # form. Inside, `Predicator::UNDEFINED` and nil are `null`; a map's
125
+ # keys must be UTF-8 Strings, and its members stand in the byte order
126
+ # of their keys; a string must be UTF-8; a Float must be finite; a
127
+ # value nested deeper than {JSON_DEPTH} has none. Byte order is the
128
+ # order the reference's runtime lists a small map's string keys in (32
129
+ # keys or fewer); it lists a larger map in an order of its own, so for
130
+ # a map of more than 32 keys this text differs from the reference's.
131
+ #
132
+ # @param value [Array, Hash]
133
+ # @return [String, nil]
134
+ def json(value, depth = 1)
135
+ return nil if depth > JSON_DEPTH
136
+
137
+ case value
138
+ when Array then json_list(value, depth)
139
+ when Hash then json_map(value, depth)
140
+ else json_scalar(value)
141
+ end
142
+ end
143
+
144
+ def json_list(list, depth)
145
+ parts = list.map { json(_1, depth + 1) || (return nil) }
146
+ "[#{parts.join(",")}]"
147
+ end
148
+
149
+ def json_map(map, depth)
150
+ return nil unless map.each_key.all? { _1.is_a?(String) && utf8?(_1) }
151
+
152
+ parts = map.keys.sort_by(&:b).map { "#{json_string(_1)}:#{json(map[_1], depth + 1) || (return nil)}" }
153
+ "{#{parts.join(",")}}"
154
+ end
155
+
156
+ def json_scalar(value)
157
+ case value
158
+ when nil, Predicator::UNDEFINED then "null"
159
+ when true, false, Integer then value.to_s
160
+ when Float then float(value) if value.finite?
161
+ when String then json_string(value) if utf8?(value)
162
+ end
163
+ end
164
+
165
+ # A UTF-8 string as JSON text, as the reference's runtime writes one:
166
+ # `"` and `\` escaped, the control characters with a short escape
167
+ # written so, every other control character below U+0020 as `\u` and
168
+ # four upper-case hex digits, everything else as it is.
169
+ def json_string(text)
170
+ body = utf8(text).gsub(/["\\\x00-\x1f]/) { SHORT_ESCAPES.fetch(_1) { |char| format("\\u%04X", char.ord) } }
171
+ "\"#{body}\""
172
+ end
173
+
174
+ # Whether `text`'s bytes are UTF-8.
175
+ def utf8?(text) = utf8(text).valid_encoding?
176
+
177
+ # `text`'s bytes read as UTF-8.
178
+ def utf8(text) = text.encoding == ::Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
179
+
180
+ # A finite Float as the reference's runtime writes it: the shortest
181
+ # digits that read back as the same number, in the plain form (a point,
182
+ # and `.0` when whole) or the exponent form (one digit, a point, at
183
+ # least one more digit, `e` and the exponent, signed only when
184
+ # negative), whichever is shorter, the plain form on a tie; from 2 to
185
+ # the 53rd up always the exponent form. Zero is `0.0`, and negative
186
+ # zero `-0.0`.
187
+ #
188
+ # @param number [Float] a finite Float
189
+ # @return [String]
190
+ def float(number)
191
+ sign = number.to_s.start_with?("-") ? "-" : ""
192
+ return "#{sign}0.0" if number.zero?
193
+
194
+ digits, point = shortest_digits(number.abs)
195
+ exponent = exponent_form(digits, point)
196
+ return sign + exponent if number.abs >= EXPONENT_FROM
197
+
198
+ plain = plain_form(digits, point)
199
+ sign + (plain.size <= exponent.size ? plain : exponent)
200
+ end
201
+
202
+ # The shortest significant digits of a positive Float, without leading
203
+ # or trailing zeros, and where the point falls: the Float is
204
+ # 0.DIGITS times ten to the power `point`. Ruby's Float#to_s writes the
205
+ # same shortest digits, in a form of its own read apart here.
206
+ def shortest_digits(magnitude)
207
+ mantissa, exponent = magnitude.to_s.split("e")
208
+ whole, fraction = mantissa.split(".")
209
+ digits = whole + fraction
210
+ significant = digits.sub(/\A0+/, "")
211
+ [significant.sub(/0+\z/, ""), whole.size + exponent.to_i - (digits.size - significant.size)]
212
+ end
213
+
214
+ def exponent_form(digits, point) = "#{digits[0]}.#{digits.size > 1 ? digits[1..] : "0"}e#{point - 1}"
215
+
216
+ def plain_form(digits, point)
217
+ return "0.#{"0" * -point}#{digits}" if point <= 0
218
+ return "#{digits}#{"0" * (point - digits.size)}.0" if digits.size <= point
219
+
220
+ "#{digits[0, point]}.#{digits[point..]}"
221
+ end
222
+ end
223
+ end
224
+ end