predicator 1.2.1 → 2.0.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +322 -46
  5. data/lib/predicator/ast.rb +74 -187
  6. data/lib/predicator/boundary.rb +237 -0
  7. data/lib/predicator/civil.rb +112 -0
  8. data/lib/predicator/duration_text.rb +92 -0
  9. data/lib/predicator/emitter/chains.rb +62 -0
  10. data/lib/predicator/emitter/statements.rb +86 -0
  11. data/lib/predicator/emitter.rb +187 -0
  12. data/lib/predicator/errors.rb +140 -0
  13. data/lib/predicator/evaluator/access.rb +108 -0
  14. data/lib/predicator/evaluator/arithmetic.rb +132 -0
  15. data/lib/predicator/evaluator/calls.rb +129 -0
  16. data/lib/predicator/evaluator/casts.rb +158 -0
  17. data/lib/predicator/evaluator/chronology.rb +88 -0
  18. data/lib/predicator/evaluator/compare.rb +158 -0
  19. data/lib/predicator/evaluator/json_functions.rb +167 -0
  20. data/lib/predicator/evaluator/json_scan.rb +303 -0
  21. data/lib/predicator/evaluator/library.rb +127 -0
  22. data/lib/predicator/evaluator/math_functions.rb +72 -0
  23. data/lib/predicator/evaluator/order.rb +120 -0
  24. data/lib/predicator/evaluator/rich_types.rb +146 -0
  25. data/lib/predicator/evaluator/statements.rb +151 -0
  26. data/lib/predicator/evaluator.rb +391 -117
  27. data/lib/predicator/float_text.rb +94 -0
  28. data/lib/predicator/iso_text.rb +99 -0
  29. data/lib/predicator/lexer/literals.rb +115 -0
  30. data/lib/predicator/lexer/numbers.rb +91 -0
  31. data/lib/predicator/lexer.rb +194 -0
  32. data/lib/predicator/parser/durations.rb +108 -0
  33. data/lib/predicator/parser/operators.rb +86 -0
  34. data/lib/predicator/parser/postfix.rb +57 -0
  35. data/lib/predicator/parser/primaries.rb +122 -0
  36. data/lib/predicator/parser/statements.rb +114 -0
  37. data/lib/predicator/parser/token_text.rb +52 -0
  38. data/lib/predicator/parser.rb +217 -438
  39. data/lib/predicator/result.rb +46 -0
  40. data/lib/predicator/tagged/reader.rb +139 -0
  41. data/lib/predicator/tagged/writer.rb +106 -0
  42. data/lib/predicator/tagged.rb +134 -0
  43. data/lib/predicator/values.rb +147 -0
  44. data/lib/predicator/version.rb +4 -1
  45. data/lib/predicator.rb +384 -14
  46. metadata +53 -147
  47. data/.gitignore +0 -11
  48. data/.rubocop.yml +0 -46
  49. data/.travis.yml +0 -5
  50. data/CODE_OF_CONDUCT.md +0 -49
  51. data/Gemfile +0 -4
  52. data/HISTORY.md +0 -51
  53. data/Rakefile +0 -24
  54. data/lib/predicator/context.rb +0 -18
  55. data/lib/predicator/lexer.rex +0 -69
  56. data/lib/predicator/lexer.rex.rb +0 -187
  57. data/lib/predicator/parser.y +0 -120
  58. data/lib/predicator/visitors/dot.rb +0 -113
  59. data/lib/predicator/visitors/each.rb +0 -16
  60. data/lib/predicator/visitors/instructions.rb +0 -183
  61. data/lib/predicator/visitors/string.rb +0 -76
  62. data/lib/predicator/visitors/visitor.rb +0 -100
  63. data/lib/predicator/visitors.rb +0 -5
  64. data/predicator.gemspec +0 -34
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "strscan"
5
+ require_relative "json_scan"
6
+
7
+ module Predicator
8
+ module Evaluator
9
+ # The library's JSON functions, over Ruby's standard json library (a
10
+ # default gem, not a dependency).
11
+ #
12
+ # `JSON.stringify` writes a value as JSON with every map's keys in sorted
13
+ # order, the order the conformance corpus fixes (a map has no order of
14
+ # its own). A Float is written as the reference writes one ({FloatText}).
15
+ # The values JSON has no form for are written as the reference writes
16
+ # them: a date and an instant as a JSON string of their ISO 8601 text, a
17
+ # duration as an object of its eight units, and undefined as the string
18
+ # "undefined". A value with no JSON form at all (a map key that is not a
19
+ # String, a String that is not valid in its encoding, an object outside
20
+ # the value domain) is written as the value's inspect, as the reference
21
+ # falls back to its own inspect, so the function does not fail on one.
22
+ #
23
+ # `JSON.parse` reads a JSON text into lists, maps with String keys and
24
+ # scalars; a key an object repeats keeps its first value, as the
25
+ # reference reads one ({JsonRead}). A text that is not JSON
26
+ # is refused with the reference's sentence naming the first fault and its
27
+ # offset in bytes ({JsonScan}): `Invalid JSON: unexpected byte 0x6F at
28
+ # position 1`, or `Invalid JSON: unexpected end of input at position 4`.
29
+ # Where the reference reports a sequence it cannot read (an unpaired
30
+ # surrogate escape, a number too large for a Float), the sentence is
31
+ # `Invalid JSON: unexpected sequence at position N`, N the offset where
32
+ # the sequence starts.
33
+ #
34
+ # Both refuse a list or a map nested more than {DEPTH_LIMIT} levels deep
35
+ # with the reason depth_limit_exceeded, the limit and the reason the
36
+ # tagged codec keeps; the outermost list or map is level one.
37
+ module JsonFunctions
38
+ # The deepest level of lists and maps either function reads or writes.
39
+ DEPTH_LIMIT = 100
40
+
41
+ # A value with no JSON form, somewhere inside the value written.
42
+ class NoForm < StandardError; end
43
+
44
+ # The JSON text of value, a list or a map at level depth.
45
+ def self.text(value, depth)
46
+ case value
47
+ when nil, true, false, Integer, String then string(value)
48
+ when Float then FloatText.spell(value)
49
+ when Array then deep(depth) && "[#{value.map { |member| text(member, depth + 1) }.join(",")}]"
50
+ when Hash then map(value, depth)
51
+ else tagged(value, depth)
52
+ end
53
+ end
54
+
55
+ # A scalar's JSON text; a String that is not valid in its encoding has
56
+ # none.
57
+ def self.string(value)
58
+ JSON.generate(value)
59
+ rescue JSON::GeneratorError, EncodingError
60
+ raise NoForm
61
+ end
62
+
63
+ def self.map(value, depth)
64
+ deep(depth)
65
+ raise NoForm unless value.each_key.all?(String)
66
+
67
+ members = value.keys.sort.map { |key| "#{string(key)}:#{text(value[key], depth + 1)}" }
68
+ "{#{members.join(",")}}"
69
+ end
70
+
71
+ def self.tagged(value, depth)
72
+ case value
73
+ when Undefined then string("undefined")
74
+ when Predicator::Date, Predicator::DateTime then string(value.to_s)
75
+ when Duration then map(Access.units(value), depth)
76
+ else raise NoForm
77
+ end
78
+ end
79
+
80
+ def self.deep(depth)
81
+ return true if depth <= DEPTH_LIMIT
82
+
83
+ raise Calls::Refusal, "depth_limit_exceeded"
84
+ end
85
+
86
+ private_class_method :string, :map, :tagged, :deep
87
+
88
+ private
89
+
90
+ def json_stringify(value)
91
+ JsonFunctions.text(value, 1)
92
+ rescue JsonFunctions::NoForm
93
+ value.inspect
94
+ end
95
+
96
+ def json_parse(text)
97
+ raise Calls::Refusal, "JSON.parse expects a string argument" unless text.is_a?(String)
98
+
99
+ fault = JsonScan.fault(text, DEPTH_LIMIT)
100
+ raise Calls::Refusal, fault if fault
101
+
102
+ JsonRead.new(text).value
103
+ rescue JSON::ParserError
104
+ # The scan accepts only what the reference reads, so Ruby's parser
105
+ # does not refuse a scalar of it; if it ever does, its own text stays
106
+ # here.
107
+ raise Calls::Refusal, "Invalid JSON: the text could not be read"
108
+ end
109
+ end
110
+ private_constant :JsonFunctions
111
+
112
+ # Reads a JSON text {JsonScan} has accepted, its bytes read as UTF-8,
113
+ # into lists, maps and scalars. Each scalar's own text is read by Ruby's
114
+ # json library; the lists and maps are built here, because that library
115
+ # keeps the last value of a key an object repeats and the reference
116
+ # keeps the first. The scan has bounded the nesting, so the reader
117
+ # recurses.
118
+ class JsonRead
119
+ # The bytes a JSON text may hold between tokens.
120
+ SPACE = /[ \t\n\r]*/
121
+ # A scalar's text: a string, a number or a literal.
122
+ SCALAR = /"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/n
123
+
124
+ def initialize(text)
125
+ @scanner = StringScanner.new(text.b)
126
+ end
127
+
128
+ # The value at the scan, and past it.
129
+ def value
130
+ @scanner.skip(SPACE)
131
+ if @scanner.skip("[") then list
132
+ elsif @scanner.skip("{") then map
133
+ else JSON.parse(@scanner.scan(SCALAR).force_encoding(Encoding::UTF_8))
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def list
140
+ items = []
141
+ items << value until closed?("]")
142
+ items
143
+ end
144
+
145
+ def map
146
+ members = {}
147
+ until closed?("}")
148
+ key = value
149
+ @scanner.skip(SPACE)
150
+ @scanner.skip(":")
151
+ member = value
152
+ members[key] = member unless members.key?(key)
153
+ end
154
+ members
155
+ end
156
+
157
+ # Whether the container closes here, past a comma between members.
158
+ def closed?(closer)
159
+ @scanner.skip(SPACE)
160
+ @scanner.skip(",")
161
+ @scanner.skip(SPACE)
162
+ !@scanner.skip(closer).nil?
163
+ end
164
+ end
165
+ private_constant :JsonRead
166
+ end
167
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Predicator
4
+ module Evaluator
5
+ # What every part of {JsonScan} shares: the byte at its offset, `@at`,
6
+ # over its bytes, `@bytes`, and the faults it throws as `:fault` with the
7
+ # sentence the text is refused with. The reference's reader reports
8
+ # three kinds: an unexpected byte at its offset, the end of the input,
9
+ # and a sequence it cannot read (its own report of that last kind is a
10
+ # term of its host language, so a sequence here is reported in a
11
+ # sentence of the same form as the other two).
12
+ module JsonFaults
13
+ private
14
+
15
+ # The byte at the scan, or nil at the end.
16
+ def here = @bytes.getbyte(@at)
17
+
18
+ # The byte at the scan; the end of the text is a fault here.
19
+ def present = here || ending(@at)
20
+
21
+ def expect(byte)
22
+ unexpected unless present == byte
23
+ @at += 1
24
+ end
25
+
26
+ def unexpected
27
+ throw :fault, format("Invalid JSON: unexpected byte 0x%<byte>02X at position %<at>d", byte: present, at: @at)
28
+ end
29
+
30
+ def ending(at) = throw(:fault, "Invalid JSON: unexpected end of input at position #{at}")
31
+ def sequence(at) = throw(:fault, "Invalid JSON: unexpected sequence at position #{at}")
32
+ end
33
+ private_constant :JsonFaults
34
+
35
+ # A JSON string's bytes, as the reference's reader reads them: a control
36
+ # byte must be escaped; the bytes from 0x80 must be well-formed UTF-8,
37
+ # refused at the first byte where the sequence breaks; and a `\u` escape
38
+ # must have its four hexadecimal digits, a high surrogate must be
39
+ # followed by a `\u` escape of a low surrogate, and a low surrogate must
40
+ # not stand alone.
41
+ #
42
+ # Where the reference reads an escape's bytes ahead, it reports a text
43
+ # too short to hold them (six bytes from the backslash, twelve for a
44
+ # surrogate pair) as the end of the whole input, not of the string.
45
+ module JsonStrings
46
+ include JsonFaults
47
+
48
+ # The bytes a backslash in a string may precede, but for `u`.
49
+ ESCAPES = '"\\/bfnrt'.bytes.freeze
50
+ # For each UTF-8 lead byte, the range each byte after it must fall in.
51
+ CONTINUATIONS = [
52
+ [0xC2..0xDF, [0x80..0xBF]], [0xE0..0xE0, [0xA0..0xBF, 0x80..0xBF]],
53
+ [0xE1..0xEC, [0x80..0xBF] * 2], [0xED..0xED, [0x80..0x9F, 0x80..0xBF]], [0xEE..0xEF, [0x80..0xBF] * 2],
54
+ [0xF0..0xF0, [0x90..0xBF] + ([0x80..0xBF] * 2)], [0xF1..0xF3, [0x80..0xBF] * 3],
55
+ [0xF4..0xF4, [0x80..0x8F] + ([0x80..0xBF] * 2)]
56
+ ].freeze
57
+ # The high and the low surrogates.
58
+ HIGH = 0xD800..0xDBFF
59
+ LOW = 0xDC00..0xDFFF
60
+
61
+ private
62
+
63
+ # A string, from its opening quote past its closing one.
64
+ def string
65
+ @at += 1
66
+ content(present) until here == 0x22
67
+ @at += 1
68
+ :after
69
+ end
70
+
71
+ # One byte of a string's content, and what it opens.
72
+ def content(byte)
73
+ case byte
74
+ when 0x5C then escape
75
+ when 0x00..0x1F then unexpected
76
+ when 0x80..0xFF then utf8(byte)
77
+ else @at += 1
78
+ end
79
+ end
80
+
81
+ def utf8(lead)
82
+ ranges = CONTINUATIONS.find { |leads, _| leads.cover?(lead) }&.last || unexpected
83
+ @at += 1
84
+ ranges.each do |range|
85
+ unexpected unless range.cover?(present)
86
+ @at += 1
87
+ end
88
+ end
89
+
90
+ # An escape, from its backslash.
91
+ def escape
92
+ start = @at
93
+ @at += 1
94
+ return unicode(start) if present == 0x75
95
+
96
+ unexpected unless ESCAPES.include?(present)
97
+ @at += 1
98
+ end
99
+
100
+ def unicode(start)
101
+ code = code_at(start)
102
+ @at = start + 6
103
+ return unless HIGH.cover?(code) || LOW.cover?(code)
104
+
105
+ sequence(start) if LOW.cover?(code)
106
+ low_half(start)
107
+ end
108
+
109
+ # The low surrogate a high one at start must be followed by.
110
+ def low_half(start)
111
+ ending(@bytes.bytesize) if @bytes.bytesize - start < 12
112
+ unexpected unless @bytes.byteslice(start + 6, 2) == "\\u"
113
+ sequence(start) unless LOW.cover?(digits(start + 6))
114
+ @at = start + 12
115
+ end
116
+
117
+ # The code a `\u` escape at start names.
118
+ def code_at(start)
119
+ ending(@bytes.bytesize) if @bytes.bytesize - start < 6
120
+ digits(start) || sequence(start)
121
+ end
122
+
123
+ def digits(start)
124
+ hex = @bytes.byteslice(start + 2, 4)
125
+ hex.to_i(16) if hex.match?(/\A\h{4}\z/)
126
+ end
127
+ end
128
+ private_constant :JsonStrings
129
+
130
+ # The scalars of a JSON text besides its strings: numbers and the three
131
+ # literals. A number too large for a Float is a sequence the reference
132
+ # cannot read, reported at the number's first byte.
133
+ module JsonScalars
134
+ include JsonStrings
135
+
136
+ # The literal each first byte begins.
137
+ LITERALS = { 0x74 => "true", 0x66 => "false", 0x6E => "null" }.transform_values(&:bytes).freeze
138
+
139
+ private
140
+
141
+ def number
142
+ start = @at
143
+ @at += 1 if here == 0x2D
144
+ present == 0x30 ? @at += 1 : digits_run
145
+ fraction_and_exponent
146
+ token = @bytes.byteslice(start, @at - start)
147
+ sequence(start) if token.match?(/[.eE]/) && !JSON.parse(token).finite?
148
+ :after
149
+ end
150
+
151
+ def fraction_and_exponent
152
+ if here == 0x2E
153
+ @at += 1
154
+ digits_run
155
+ end
156
+ return unless [0x65, 0x45].include?(here)
157
+
158
+ @at += 1
159
+ @at += 1 if [0x2B, 0x2D].include?(here)
160
+ digits_run
161
+ end
162
+
163
+ def digits_run
164
+ unexpected unless present.between?(0x30, 0x39)
165
+ @at += 1 while here&.between?(0x30, 0x39)
166
+ end
167
+
168
+ def literal(bytes)
169
+ bytes.each { |byte| expect(byte) }
170
+ :after
171
+ end
172
+ end
173
+ private_constant :JsonScalars
174
+
175
+ # Finds the first fault in a JSON text, by byte offset, as the
176
+ # reference's JSON reader reports it. JSON.parse is asked to read only a
177
+ # text this has accepted, because the sentence a malformed text is
178
+ # refused with names the offending byte and its offset, and Ruby's
179
+ # parser's own messages are its own.
180
+ #
181
+ # Content after the value is refused at the first byte after the value,
182
+ # a space included, as the reference refuses it. The scan walks the
183
+ # text's bytes once, keeping the open lists and maps on a stack of its
184
+ # own rather than recursing, so a deeply nested text cannot exhaust
185
+ # Ruby's stack. It holds no state between scans.
186
+ class JsonScan
187
+ include JsonScalars
188
+
189
+ # The bytes a JSON text may hold between tokens.
190
+ SPACE = [0x20, 0x09, 0x0A, 0x0D].freeze
191
+ # The byte that closes each kind of container.
192
+ CLOSERS = { array: 0x5D, object: 0x7D }.freeze
193
+
194
+ # Why text is not a JSON text: "Invalid JSON: " and the fault, or
195
+ # "depth_limit_exceeded" when it nests past limit levels; nil when it
196
+ # is one.
197
+ #
198
+ # @param text [String] the text, its bytes read as UTF-8
199
+ # @param limit [Integer] the deepest level of lists and maps accepted
200
+ # @return [String, nil]
201
+ def self.fault(text, limit) = new(text, limit).fault
202
+
203
+ def initialize(text, limit)
204
+ @bytes = text.b
205
+ @limit = limit
206
+ @at = 0
207
+ @open = []
208
+ end
209
+
210
+ def fault
211
+ catch(:fault) do
212
+ space
213
+ walk
214
+ trailing
215
+ end
216
+ end
217
+
218
+ private
219
+
220
+ # Only space may follow the value; anything else is refused at the
221
+ # first byte after the value.
222
+ def trailing
223
+ after = @at
224
+ space
225
+ return if @at == @bytes.bytesize
226
+
227
+ @at = after
228
+ unexpected
229
+ end
230
+
231
+ # Scans one value and, while containers are open, the rest of each.
232
+ def walk
233
+ state = value
234
+ state = state == :first ? first_member : next_member until state == :done
235
+ end
236
+
237
+ def first_member
238
+ space
239
+ return close if closer?
240
+
241
+ member_start
242
+ value
243
+ end
244
+
245
+ def next_member
246
+ return :done if @open.empty?
247
+
248
+ space
249
+ return close if closer?
250
+
251
+ expect(0x2C)
252
+ space
253
+ member_start
254
+ value
255
+ end
256
+
257
+ def closer? = here == CLOSERS.fetch(@open.last)
258
+
259
+ def close
260
+ @at += 1
261
+ @open.pop
262
+ :after
263
+ end
264
+
265
+ # An object's member opens with its key and a colon.
266
+ def member_start
267
+ return unless @open.last == :object
268
+
269
+ unexpected unless present == 0x22
270
+ string
271
+ space
272
+ expect(0x3A)
273
+ space
274
+ end
275
+
276
+ # Scans a scalar (answering :after) or enters a container (:first).
277
+ def value
278
+ byte = here
279
+ case byte
280
+ when 0x5B then enter(:array)
281
+ when 0x7B then enter(:object)
282
+ when 0x22 then string
283
+ when 0x2D, 0x30..0x39 then number
284
+ when *LITERALS.keys then literal(LITERALS.fetch(byte))
285
+ else unexpected
286
+ end
287
+ end
288
+
289
+ def enter(kind)
290
+ throw :fault, "depth_limit_exceeded" if @open.size >= @limit
291
+
292
+ @open.push(kind)
293
+ @at += 1
294
+ :first
295
+ end
296
+
297
+ def space
298
+ @at += 1 while SPACE.include?(here)
299
+ end
300
+ end
301
+ private_constant :JsonScan
302
+ end
303
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "math_functions"
4
+ require_relative "json_functions"
5
+
6
+ module Predicator
7
+ module Evaluator
8
+ # The package's function library: the functions the reference ships as
9
+ # its builtins (its string, list, date, math and JSON functions), by the
10
+ # names a `call` reaches them under, with the counts of arguments each
11
+ # takes. {Calls} looks a name up here after the caller's own functions.
12
+ #
13
+ # Each function is a private method of {Machine} that takes the call's
14
+ # arguments and answers its value, or raises {Calls::Refusal} with the
15
+ # sentence the reference refuses those arguments with. This module holds
16
+ # the string, list and date functions; {MathFunctions} and
17
+ # {JsonFunctions} hold the rest.
18
+ #
19
+ # A string's length and a substring's offsets count grapheme clusters,
20
+ # and `index_of` answers a byte offset into the string's UTF-8, as the
21
+ # reference's own string functions do. `trim` removes the characters
22
+ # with the Unicode White_Space property from both ends.
23
+ module Library
24
+ include MathFunctions
25
+ include JsonFunctions
26
+
27
+ # One library function: the counts of arguments it takes and the
28
+ # Machine method that runs it.
29
+ Entry = Data.define(:counts, :handler)
30
+
31
+ # The library, by name.
32
+ FUNCTIONS = {
33
+ "len" => [1..1, :string_len], "upper" => [1..1, :string_upper], "lower" => [1..1, :string_lower],
34
+ "trim" => [1..1, :string_trim], "starts_with" => [2..2, :string_starts_with?],
35
+ "ends_with" => [2..2, :string_ends_with?], "substring" => [2..3, :string_substring],
36
+ "index_of" => [2..2, :string_index_of], "concat" => [2..2, :list_concat],
37
+ "Date.year" => [1..1, :date_year], "Date.month" => [1..1, :date_month], "Date.day" => [1..1, :date_day],
38
+ "Date.now" => [0..0, :date_now],
39
+ "Math.pow" => [2..2, :math_pow], "Math.sqrt" => [1..1, :math_sqrt], "Math.abs" => [1..1, :math_abs],
40
+ "Math.floor" => [1..1, :math_floor], "Math.ceil" => [1..1, :math_ceil], "Math.round" => [1..1, :math_round],
41
+ "Math.min" => [2..2, :math_min], "Math.max" => [2..2, :math_max], "Math.random" => [0..0, :math_random],
42
+ "JSON.stringify" => [1..1, :json_stringify], "JSON.parse" => [1..1, :json_parse]
43
+ }.transform_values { |counts, handler| Entry.new(counts:, handler:) }.freeze
44
+
45
+ # The ends `trim` removes.
46
+ LEADING_SPACE = /\A\p{White_Space}+/
47
+ TRAILING_SPACE = /\p{White_Space}+\z/
48
+ # What substring says of arguments of the wrong types, with two
49
+ # arguments (true) and with three.
50
+ SUBSTRING_TYPES = {
51
+ true => "substring() expects a string and an integer start index",
52
+ false => "substring() expects a string, an integer start index, and an integer length"
53
+ }.freeze
54
+ private_constant :LEADING_SPACE, :TRAILING_SPACE, :SUBSTRING_TYPES
55
+
56
+ private
57
+
58
+ def string_len(value) = string_arg("len", value).grapheme_clusters.size
59
+ def string_upper(value) = string_arg("upper", value).upcase
60
+ def string_lower(value) = string_arg("lower", value).downcase
61
+ def string_trim(value) = string_arg("trim", value).sub(LEADING_SPACE, "").sub(TRAILING_SPACE, "")
62
+
63
+ def string_arg(name, value)
64
+ return value if value.is_a?(String)
65
+
66
+ raise Calls::Refusal, "#{name}() expects a string argument"
67
+ end
68
+
69
+ def string_starts_with?(value, prefix) = two_strings("starts_with", value, prefix).start_with?(prefix)
70
+ def string_ends_with?(value, suffix) = two_strings("ends_with", value, suffix).end_with?(suffix)
71
+ def string_index_of(value, sub) = two_strings("index_of", value, sub).byteindex(sub) || -1
72
+
73
+ def two_strings(name, value, other)
74
+ return value if value.is_a?(String) && other.is_a?(String)
75
+
76
+ raise Calls::Refusal, "#{name}() expects two string arguments"
77
+ end
78
+
79
+ # The grapheme clusters from start, all of them or length of them. A
80
+ # start or a length past the end answers what the string has. The
81
+ # call's shape is its count of arguments, so a third argument of null
82
+ # is a length that is not an Integer, not a missing one.
83
+ def string_substring(value, start, *length)
84
+ two = length.empty?
85
+ substring_types(value, start, length, two)
86
+ substring_signs(start, length.first, two)
87
+ clusters = value.grapheme_clusters
88
+ (clusters[start, two ? clusters.size : length.first] || []).join
89
+ end
90
+
91
+ def substring_types(value, start, length, two)
92
+ return if value.is_a?(String) && start.is_a?(Integer) && length.all?(Integer)
93
+
94
+ raise Calls::Refusal, SUBSTRING_TYPES.fetch(two)
95
+ end
96
+
97
+ def substring_signs(start, length, two)
98
+ return unless start.negative? || (!two && length.negative?)
99
+
100
+ raise Calls::Refusal, "substring() expects a non-negative start index#{" and length" unless two}"
101
+ end
102
+
103
+ def list_concat(left, right)
104
+ return left + right if left.is_a?(Array) && right.is_a?(Array)
105
+
106
+ raise Calls::Refusal, "concat() expects two list arguments"
107
+ end
108
+
109
+ def date_year(value) = civil_date("Date.year", value).year
110
+ def date_month(value) = civil_date("Date.month", value).month
111
+ def date_day(value) = civil_date("Date.day", value).day
112
+
113
+ # The date a date names, or the date in UTC an instant falls on.
114
+ def civil_date(name, value)
115
+ return value if value.is_a?(Predicator::Date)
116
+ return Civil.date_of(value) if value.is_a?(Predicator::DateTime)
117
+
118
+ raise Calls::Refusal, "#{name}() expects a date or datetime argument"
119
+ end
120
+
121
+ # The current time of this evaluation: the clock the caller passed,
122
+ # read at most once per evaluation, as relative_date reads it.
123
+ def date_now = now
124
+ end
125
+ private_constant :Library
126
+ end
127
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Predicator
4
+ module Evaluator
5
+ # The library's math functions, as the reference's own: each takes
6
+ # numbers (an Integer or a Float) and refuses anything else with the
7
+ # reference's sentence for it.
8
+ #
9
+ # Integers stay exact where the reference keeps them exact: `Math.pow`
10
+ # of two Integers with a non-negative exponent, `Math.sqrt` of an
11
+ # Integer with an exact Integer root, `Math.abs`, `Math.min` and
12
+ # `Math.max` of Integers. `Math.floor`, `Math.ceil` and `Math.round`
13
+ # answer an Integer, rounding half away from zero. Where the reference's
14
+ # float arithmetic has no real, finite answer (`Math.pow(-8, 0.5)`, an
15
+ # overflow), the call is refused rather than answering a complex number
16
+ # or an infinity, which the value domain does not hold.
17
+ module MathFunctions
18
+ private
19
+
20
+ def math_pow(base, exponent)
21
+ two_numbers("Math.pow", base, exponent)
22
+ return base**exponent if base.is_a?(Integer) && exponent.is_a?(Integer) && !exponent.negative?
23
+
24
+ power = base.to_f**exponent.to_f
25
+ return power if power.is_a?(Float) && power.finite?
26
+
27
+ raise Calls::Refusal, "Math.pow has no finite real answer for #{base} and #{exponent}"
28
+ end
29
+
30
+ def math_sqrt(value)
31
+ raise Calls::Refusal, "Math.sqrt expects a numeric argument" unless number?(value)
32
+ raise Calls::Refusal, "Math.sqrt expects a non-negative number" if value.negative?
33
+
34
+ if value.is_a?(Integer)
35
+ root = Integer.sqrt(value)
36
+ return root if root * root == value
37
+ end
38
+ Math.sqrt(value)
39
+ end
40
+
41
+ def math_abs(value) = numeric("Math.abs", value).abs
42
+ def math_floor(value) = numeric("Math.floor", value).to_f.floor
43
+ def math_ceil(value) = numeric("Math.ceil", value).to_f.ceil
44
+ def math_round(value) = numeric("Math.round", value).round
45
+
46
+ # The smaller of the two, the first when they are equal.
47
+ def math_min(left, right) = two_numbers("Math.min", left, right) && [left, right].min
48
+
49
+ # The larger of the two, the first when they are equal.
50
+ def math_max(left, right) = two_numbers("Math.max", left, right) && [left, right].max
51
+
52
+ # A Float from 0.0 up to 1.0, from a generator of its own, so that no
53
+ # state outside this call is read or moved.
54
+ def math_random = Random.new.rand
55
+
56
+ def number?(value) = value.is_a?(Integer) || value.is_a?(Float)
57
+
58
+ def numeric(name, value)
59
+ return value if number?(value)
60
+
61
+ raise Calls::Refusal, "#{name} expects a numeric argument"
62
+ end
63
+
64
+ def two_numbers(name, left, right)
65
+ return true if number?(left) && number?(right)
66
+
67
+ raise Calls::Refusal, "#{name} expects two numeric arguments"
68
+ end
69
+ end
70
+ private_constant :MathFunctions
71
+ end
72
+ end