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
@@ -1,153 +1,427 @@
1
- require "date"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "values"
4
+ require_relative "errors"
5
+ require_relative "result"
6
+ require_relative "float_text"
7
+ require_relative "duration_text"
8
+ require_relative "iso_text"
9
+ require_relative "boundary"
10
+ require_relative "evaluator/chronology"
11
+ require_relative "evaluator/arithmetic"
12
+ require_relative "evaluator/access"
13
+ require_relative "evaluator/rich_types"
14
+ require_relative "evaluator/library"
15
+ require_relative "evaluator/calls"
16
+ require_relative "evaluator/statements"
17
+ require_relative "evaluator/casts"
2
18
 
3
19
  module Predicator
4
- class Evaluator
5
- attr_reader :instructions, :stack, :context
6
-
7
- def initialize instructions, context_data={}
8
- @instructions = instructions
9
- @context = context_for context_data
10
- @stack = []
11
- @ip = 0
12
- end
20
+ # The stack machine that runs an instruction list against a context. It
21
+ # runs in one of two modes, carried by the entry point and not by the
22
+ # list: in expression mode ({evaluate}) the answer is the top of the stack
23
+ # at halt; in statement mode ({execute}) it is the context at halt. The
24
+ # instructions mean the same in both. It is private to the package: the
25
+ # public entry points ({Predicator.evaluate}, {Predicator.execute} and
26
+ # {Predicator.execute_value}) check what they are handed against the
27
+ # value domain ({Boundary}) and then call it.
28
+ #
29
+ # A program is a flat list of instructions run in order from index 0; it
30
+ # halts when the instruction pointer reaches or passes the end, so a jump
31
+ # past the last instruction is a normal halt. The rules every opcode keeps
32
+ # are the reference's ISA (docs/isa.md, section 2): opcodes validate and
33
+ # never coerce; a malformed operand is an unknown instruction; stack depth
34
+ # is checked before types.
35
+ #
36
+ # Each instruction this build runs is one row of {INSTRUCTIONS}: the shapes
37
+ # of its operands, the instruction-set version that retired it, and the
38
+ # Machine method that runs it. A later opcode is one more row and one more
39
+ # method; the loop, the operand check and the halt rules do not change.
40
+ #
41
+ # Options are per call: nothing here is configured globally, and every
42
+ # evaluation builds its own Machine. That includes the clock: an
43
+ # instruction that reads the time reads the clock the caller passed, at
44
+ # most once per evaluation, so every such instruction in one evaluation
45
+ # reads the same instant. It includes the host's functions too: `call`
46
+ # reaches the ones the caller passed to this evaluation, then the
47
+ # package's own library, and nothing registered anywhere else. And it
48
+ # includes the loop budget: the back edges one evaluation may take.
49
+ module Evaluator
50
+ # The instruction-set version this build runs: an opcode retired at or
51
+ # below it is refused.
52
+ ISA_VERSION = 6
13
53
 
14
- def context_for context_data
15
- return context_data unless context_data.kind_of? Hash
16
- Context.new context_data
17
- end
54
+ # What a load of a root the context does not bind does: :undefined
55
+ # pushes undefined (and the answer is an unbound-variable error if
56
+ # undefined is what the program answers); :error stops at the load.
57
+ UNBOUND_POLICIES = %i[undefined error].freeze
18
58
 
19
- def result
20
- while @ip < instructions.length
21
- process @instructions[@ip]
22
- @ip += 1
23
- end
24
- stack.pop
25
- end
59
+ # The back edges (`jump_backward`) one evaluation may take when the
60
+ # caller names no budget, the reference's default.
61
+ DEFAULT_LOOP_BUDGET = 10_000
26
62
 
27
- def process instruction
28
- case instruction.first
29
- when "not" then stack.push !stack.pop
30
- when "jfalse" then jump_if_false instruction.last
31
- when "jtrue" then jump_if_true instruction.last
32
- when "lit", "array" then stack.push instruction.last
33
- when "load" then stack.push context[instruction.last]
34
- when "to_bool" then stack.push !!stack.pop
35
- when "to_int" then stack.push to_int(stack.pop)
36
- when "to_str" then stack.push to_str(stack.pop)
37
- when "to_date" then stack.push to_date(stack.pop)
38
- when "date_ago" then stack.push date_ago(stack.pop)
39
- when "date_from_now" then stack.push date_from_now(stack.pop)
40
- when "blank" then stack.push blank?(stack.pop)
41
- when "present" then stack.push !blank?(stack.pop)
42
- when "compare"
43
- if instruction.last == "BETWEEN"
44
- compare_BETWEEN
45
- else
46
- compare instruction.last
47
- end
48
- end
63
+ # The clock an evaluation reads when the caller passes none: the system's
64
+ # real-time clock, read to the microsecond, as an instant in UTC. It
65
+ # holds no state.
66
+ SYSTEM_CLOCK = lambda do
67
+ micros = Process.clock_gettime(Process::CLOCK_REALTIME, :microsecond)
68
+ Predicator::DateTime.new(epoch_seconds: micros.div(1_000_000), microsecond: micros % 1_000_000)
49
69
  end
50
70
 
51
- def to_int val
52
- if val.nil? || (val.is_a?(String) && val.empty?)
53
- nil
54
- else
55
- val.to_i
56
- end
71
+ # One instruction's row: the shape of each operand, the version that
72
+ # retired it (nil while it is live) and the Machine method that runs it.
73
+ Row = Data.define(:operands, :removed_in, :handler) do
74
+ # Whether the opcode was retired at or below {ISA_VERSION}.
75
+ def retired? = !removed_in.nil? && removed_in <= ISA_VERSION
57
76
  end
58
77
 
59
- def to_str val
60
- val.nil? ? nil : val.to_s
61
- end
78
+ # Each operand shape, as a test of one operand.
79
+ SHAPES = {
80
+ value: ->(_operand) { true },
81
+ string: ->(operand) { operand.is_a?(String) },
82
+ list: ->(operand) { operand.is_a?(Array) },
83
+ comparison_operator: ->(operand) { Compare::OPERATORS.include?(operand) },
84
+ positive_integer: ->(operand) { operand.is_a?(Integer) && operand.positive? },
85
+ count: ->(operand) { operand.is_a?(Integer) && !operand.negative? },
86
+ type_name: ->(operand) { Casts::TYPE_NAMES.include?(operand) }
87
+ }.freeze
62
88
 
63
- def to_date val
64
- val.nil? ? nil : Date.parse(val)
65
- end
89
+ # The instructions this build knows, by opcode. `and` and `or` were
90
+ # retired at instruction-set version 3; they keep their rows so that a
91
+ # list carrying them is refused as retired rather than as unknown.
92
+ INSTRUCTIONS = {
93
+ "lit" => Row.new(operands: [:value], removed_in: nil, handler: :push),
94
+ "load" => Row.new(operands: [:string], removed_in: nil, handler: :load_root),
95
+ "compare" => Row.new(operands: [:comparison_operator], removed_in: nil, handler: :compare),
96
+ "not" => Row.new(operands: [], removed_in: nil, handler: :logical_not),
97
+ "unary_bang" => Row.new(operands: [], removed_in: nil, handler: :unary_bang),
98
+ "unary_minus" => Row.new(operands: [], removed_in: nil, handler: :unary_minus),
99
+ "jump_if_falsy_or_pop" => Row.new(operands: [:positive_integer], removed_in: nil,
100
+ handler: :jump_if_falsy_or_pop),
101
+ "jump_if_true_or_pop" => Row.new(operands: [:positive_integer], removed_in: nil,
102
+ handler: :jump_if_true_or_pop),
103
+ "add" => Row.new(operands: [], removed_in: nil, handler: :add),
104
+ "subtract" => Row.new(operands: [], removed_in: nil, handler: :subtract),
105
+ "multiply" => Row.new(operands: [], removed_in: nil, handler: :multiply),
106
+ "divide" => Row.new(operands: [], removed_in: nil, handler: :divide),
107
+ "modulo" => Row.new(operands: [], removed_in: nil, handler: :modulo),
108
+ "access" => Row.new(operands: [:string], removed_in: nil, handler: :access),
109
+ "bracket_access" => Row.new(operands: [], removed_in: nil, handler: :bracket_access),
110
+ "in" => Row.new(operands: [], removed_in: nil, handler: :membership_in),
111
+ "contains" => Row.new(operands: [], removed_in: nil, handler: :membership_contains),
112
+ "make_list" => Row.new(operands: [:count], removed_in: nil, handler: :make_list),
113
+ "duration" => Row.new(operands: [:list], removed_in: nil, handler: :duration),
114
+ "relative_date" => Row.new(operands: [:string], removed_in: nil, handler: :relative_date),
115
+ "object_new" => Row.new(operands: [], removed_in: nil, handler: :object_new),
116
+ "object_set" => Row.new(operands: [:string], removed_in: nil, handler: :object_set),
117
+ "call" => Row.new(operands: %i[string count], removed_in: nil, handler: :call_function),
118
+ "store" => Row.new(operands: [:count], removed_in: nil, handler: :store),
119
+ "pop" => Row.new(operands: [], removed_in: nil, handler: :discard),
120
+ "jump" => Row.new(operands: [:positive_integer], removed_in: nil, handler: :jump),
121
+ "pop_jump_if_falsy" => Row.new(operands: [:positive_integer], removed_in: nil, handler: :pop_jump_if_falsy),
122
+ "jump_backward" => Row.new(operands: [:positive_integer], removed_in: nil, handler: :jump_backward),
123
+ "cast" => Row.new(operands: [:type_name], removed_in: nil, handler: :cast),
124
+ "and" => Row.new(operands: [], removed_in: 3, handler: nil),
125
+ "or" => Row.new(operands: [], removed_in: 3, handler: nil)
126
+ }.freeze
127
+
128
+ # The options one evaluation runs under, checked: the unbound policy,
129
+ # the clock, the host's functions and the loop budget.
130
+ Settings = Data.define(:on_unbound, :clock, :functions, :loop_budget)
131
+ private_constant :Settings
66
132
 
67
- def date_ago seconds
68
- past_time = Time.now - seconds
69
- to_date past_time.strftime "%Y-%m-%d"
133
+ # What a run in statement mode answers: the context at halt and the
134
+ # value the last `pop` discarded (undefined when no `pop` ran), or the
135
+ # error that stopped the run and the context as it stood then, which
136
+ # holds every write made before the failing instruction.
137
+ #
138
+ # @!attribute [r] error
139
+ # @return [Object, nil] the error value that stopped the run, or nil
140
+ # @!attribute [r] context
141
+ # @return [Hash] the context at halt, or when the run stopped
142
+ # @!attribute [r] value
143
+ # @return [Object] what the last `pop` discarded, or undefined
144
+ StatementResult = Data.define(:error, :context, :value) do
145
+ # Whether the run reached its halt.
146
+ def ok? = error.nil?
70
147
  end
71
148
 
72
- def date_from_now seconds
73
- future_time = Time.now + seconds
74
- to_date future_time.strftime "%Y-%m-%d"
149
+ # Runs instructions against context in expression mode: the answer is
150
+ # the top of the stack at halt.
151
+ #
152
+ # @param instructions [Array] the instruction list
153
+ # @param context [Hash{String => Object}] the variables a load reads
154
+ # @param options [Hash] the evaluation's options, each optional:
155
+ # @option options [Symbol] :on_unbound one of {UNBOUND_POLICIES};
156
+ # :undefined when not given
157
+ # @option options [#call, nil] :clock the clock: called with no argument,
158
+ # it answers the current time as a {Predicator::DateTime}. It is called
159
+ # at most once, and only when an instruction reads the time. nil, the
160
+ # default, means {SYSTEM_CLOCK}. A clock that answers anything else, or
161
+ # raises a StandardError, stops the run with an EvaluationError whose
162
+ # reason is clock_failed, whichever instruction read it.
163
+ # @option options [Hash{String => #call}] :functions the host's functions
164
+ # for this evaluation, by the name a `call` instruction names. Each is
165
+ # called with the call's arguments, first argument first, and answers
166
+ # the call's value; the counts of arguments it accepts are the ones its
167
+ # parameters take. A name here shadows the library function of the
168
+ # same name, for this evaluation only. {Calls} says how a call is
169
+ # refused.
170
+ # @option options [Integer] :loop_budget the back edges the evaluation
171
+ # may take, at least 0; {DEFAULT_LOOP_BUDGET} when not given. A
172
+ # `jump_backward` that finds the budget spent stops the run with
173
+ # loop_budget_exceeded.
174
+ # @return [Result] the top of the stack at halt, or the error that
175
+ # stopped the run
176
+ # @raise [ArgumentError] when an argument is not of its kind, or an
177
+ # option is unknown or malformed (a caller's bug, not an outcome of the
178
+ # program)
179
+ def self.evaluate(instructions, context = {}, **options) = machine(instructions, context, options).run
180
+
181
+ # Runs instructions against context in statement mode: the answer is
182
+ # the context at halt. An empty stack at halt is a normal halt, and
183
+ # whatever the stack holds then is discarded. The context passed is
184
+ # never changed; a `store` writes a new one.
185
+ #
186
+ # @param instructions [Array] the instruction list
187
+ # @param context [Hash{String => Object}] the variables a load reads
188
+ # @param options [Hash] the options {evaluate} takes
189
+ # @return [StatementResult] the context at halt and the value the last `pop`
190
+ # discarded, or the error that stopped the run
191
+ # @raise [ArgumentError] as {evaluate} raises it
192
+ def self.execute(instructions, context = {}, **options)
193
+ machine(instructions, context, options).run_statements
75
194
  end
76
195
 
77
- def blank? val
78
- val.respond_to?(:empty?) ? !!val.empty? : !val
196
+ def self.machine(instructions, context, options)
197
+ raise ArgumentError, "instructions must be an Array, not #{instructions.inspect}" unless instructions.is_a?(Array)
198
+ raise ArgumentError, "context must be a Hash, not #{context.inspect}" unless context.is_a?(Hash)
199
+
200
+ Machine.new(instructions, context, settings(**options))
79
201
  end
202
+ private_class_method :machine
80
203
 
81
- def jump_if_false offset
82
- if stack[-1] == false
83
- adjusted_offset = offset - 1
84
- @ip += adjusted_offset
85
- else
86
- stack.pop
204
+ def self.settings(on_unbound: :undefined, clock: nil, functions: {}, loop_budget: DEFAULT_LOOP_BUDGET)
205
+ unless UNBOUND_POLICIES.include?(on_unbound)
206
+ raise ArgumentError, "on_unbound must be one of #{UNBOUND_POLICIES.inspect}, not #{on_unbound.inspect}"
207
+ end
208
+ raise ArgumentError, "clock must respond to call, not #{clock.inspect}" unless clock?(clock)
209
+ unless loop_budget.is_a?(Integer) && !loop_budget.negative?
210
+ raise ArgumentError, "loop_budget must be an Integer of at least 0, not #{loop_budget.inspect}"
87
211
  end
212
+
213
+ check_functions(functions)
214
+ Settings.new(on_unbound:, clock: clock || SYSTEM_CLOCK, functions:, loop_budget:)
88
215
  end
216
+ private_class_method :settings
217
+
218
+ def self.clock?(clock) = clock.nil? || clock.respond_to?(:call)
219
+ private_class_method :clock?
89
220
 
90
- def jump_if_true offset
91
- if stack[-1] == true
92
- adjusted_offset = offset - 1
93
- @ip += adjusted_offset
94
- else
95
- stack.pop
221
+ def self.check_functions(functions)
222
+ raise ArgumentError, "functions must be a Hash, not #{functions.inspect}" unless functions.is_a?(Hash)
223
+
224
+ functions.each do |name, callable|
225
+ raise ArgumentError, "a function's name is a String, not #{name.inspect}" unless name.is_a?(String)
226
+ raise ArgumentError, "the function #{name} does not respond to call" unless callable.respond_to?(:call)
96
227
  end
97
228
  end
229
+ private_class_method :check_functions
230
+
231
+ # The error values the loop and every opcode's handler refuse with when
232
+ # the stack or the instruction itself is wrong. {Machine} includes it.
233
+ module Refusals
234
+ private
98
235
 
99
- def compare comparison
100
- right = stack.pop
101
- left = stack.pop
102
- if left.nil? || right.nil?
103
- stack.push false
104
- else
105
- stack.push send("compare_#{comparison}", left, right)
236
+ def insufficient(opcode)
237
+ EvaluationError.new(reason: "insufficient_operands",
238
+ message: "#{opcode} needs more operands than the stack holds")
106
239
  end
107
- rescue StandardError
108
- stack.push false
109
- end
110
240
 
111
- def compare_EQ left, right
112
- left == right
113
- end
241
+ def unknown(at)
242
+ EvaluationError.new(reason: "unknown_instruction", message: "Unknown instruction at index #{at}")
243
+ end
114
244
 
115
- def compare_GT left, right
116
- left > right
245
+ def retired(opcode, version)
246
+ EvaluationError.new(reason: "retired_opcode",
247
+ message: "#{opcode} was retired at instruction-set version #{version}")
248
+ end
117
249
  end
250
+ private_constant :Refusals
118
251
 
119
- def compare_LT left, right
120
- left < right
121
- end
252
+ # One evaluation: the program, the context (which a `store` replaces),
253
+ # the policy, the clock, the host's functions, the loop budget left, the
254
+ # stack, the first root a load did not find bound and the value the last
255
+ # `pop` discarded. Each handler answers the index of the next
256
+ # instruction, or the error value that stops the run. The handlers of
257
+ # the arithmetic opcodes are in {Arithmetic}, those of access,
258
+ # membership and list construction in {Access}, those of durations,
259
+ # relative dates and maps in {RichTypes}, that of `call` in {Calls},
260
+ # which includes the library {Library} defines, and those of the
261
+ # statement and jump opcodes in {Statements}, and that of `cast` in
262
+ # {Casts}; Machine includes all six, and the refusals they share in
263
+ # {Refusals}.
264
+ class Machine
265
+ include Refusals
266
+ include Arithmetic
267
+ include Access
268
+ include RichTypes
269
+ include Calls
270
+ include Statements
271
+ include Casts
122
272
 
123
- def compare_IN left, right
124
- right.include? left
125
- end
273
+ def initialize(program, context, settings)
274
+ @program = program
275
+ @context = context
276
+ @on_unbound = settings.on_unbound
277
+ @clock = settings.clock
278
+ @functions = settings.functions
279
+ @budget = settings.loop_budget
280
+ @stack = []
281
+ @unbound = nil
282
+ @last_value = UNDEFINED
283
+ end
126
284
 
127
- def compare_NOTIN left, right
128
- !right.include? left
129
- end
285
+ # Runs in expression mode: the answer is the top of the stack at halt.
286
+ def run = drive&.then { |error| Results.failure(error) } || halt
130
287
 
131
- def compare_STARTSWITH left, right
132
- left.start_with? right
133
- end
288
+ # Runs in statement mode: the answer is the context at halt. Neither
289
+ # halt rule of expression mode applies, as there is no stack value to
290
+ # answer.
291
+ def run_statements = StatementResult.new(error: drive, context: @context, value: @last_value)
134
292
 
135
- def compare_ENDSWITH left, right
136
- left.end_with? right
137
- end
293
+ private
294
+
295
+ # Runs the program until it halts, answering the error that stopped it,
296
+ # or nil when it halted.
297
+ def drive
298
+ at = 0
299
+ while at < @program.size
300
+ step = step(@program[at], at)
301
+ return step unless step.is_a?(Integer)
302
+
303
+ at = step
304
+ end
305
+ nil
306
+ rescue ClockFault => e
307
+ e.error
308
+ end
309
+
310
+ # The answer at halt. An empty stack is an error; undefined on top is
311
+ # an unbound-variable error naming the first root a load did not find,
312
+ # when a load did not find one.
313
+ def halt
314
+ return Results.failure(EvaluationError.new(reason: "empty_stack", message: "the stack is empty at halt")) \
315
+ if @stack.empty?
316
+ return Results.failure(unbound(@unbound)) if UNDEFINED.equal?(@stack.last) && @unbound
317
+
318
+ Results.ok(@stack.last)
319
+ end
138
320
 
139
- def compare_BETWEEN
140
- max = stack.pop
141
- min = stack.pop
142
- val = stack.pop
143
- if max.nil? || min.nil? || val.nil?
144
- stack.push false
145
- else
146
- result = val.between? min, max
147
- stack.push result
148
- end
149
- rescue StandardError
150
- stack.push false
321
+ # A retired opcode is refused by its name whatever its operands, as the
322
+ # reference refuses it; any other instruction's shape is checked, and
323
+ # only then does it run.
324
+ def step(instruction, at)
325
+ named = INSTRUCTIONS[instruction.first] if instruction.is_a?(Array)
326
+ return retired(instruction.first, named.removed_in) if named&.retired?
327
+
328
+ row = row_for(instruction)
329
+ return unknown(at) unless row
330
+
331
+ send(row.handler, at, *instruction.drop(1))
332
+ end
333
+
334
+ def row_for(instruction)
335
+ return unless instruction.is_a?(Array)
336
+
337
+ row = INSTRUCTIONS[instruction.first]
338
+ return unless row && instruction.size == row.operands.size + 1
339
+
340
+ row if row.operands.each_with_index.all? { |shape, index| SHAPES.fetch(shape).call(instruction[index + 1]) }
341
+ end
342
+
343
+ def push(at, value)
344
+ @stack.push(value)
345
+ at + 1
346
+ end
347
+
348
+ # A root the context binds is read as it is, null and undefined
349
+ # included; one it does not bind is undefined, recorded for the halt
350
+ # rule, or under the :error policy an error at once.
351
+ def load_root(at, name)
352
+ return push(at, @context[name]) if @context.key?(name)
353
+ return unbound(name) if @on_unbound == :error
354
+
355
+ @unbound ||= name
356
+ push(at, UNDEFINED)
357
+ end
358
+
359
+ def compare(at, operator)
360
+ return insufficient("compare") if @stack.size < 2
361
+
362
+ right = @stack.pop
363
+ left = @stack.pop
364
+ push(at, Compare.call(operator, left, right))
365
+ end
366
+
367
+ def logical_not(at) = negate(at, "logical_not")
368
+ def unary_bang(at) = negate(at, "unary_bang")
369
+
370
+ def negate(at, operation)
371
+ return insufficient(operation) if @stack.empty?
372
+
373
+ operand = @stack.pop
374
+ return mismatch(operation, "a boolean", operand) unless true.equal?(operand) || false.equal?(operand)
375
+
376
+ push(at, !operand)
377
+ end
378
+
379
+ def unary_minus(at)
380
+ return insufficient("unary_minus") if @stack.empty?
381
+
382
+ operand = @stack.pop
383
+ return mismatch("unary_minus", "a number", operand) unless operand.is_a?(Integer) || operand.is_a?(Float)
384
+
385
+ push(at, -operand)
386
+ end
387
+
388
+ def jump_if_falsy_or_pop(at, offset) = conditional_jump(at, offset, "jump_if_falsy_or_pop", false)
389
+ def jump_if_true_or_pop(at, offset) = conditional_jump(at, offset, "jump_if_true_or_pop", true)
390
+
391
+ # Jumps when the top's truth is jump_on, leaving it on the stack as the
392
+ # answer; otherwise pops it and falls through. False, null and
393
+ # undefined are falsy, exactly true is true, and anything else is a
394
+ # type mismatch.
395
+ def conditional_jump(at, offset, opcode, jump_on)
396
+ return insufficient(opcode) if @stack.empty?
397
+
398
+ truth = truth(@stack.last)
399
+ return mismatch(opcode, "a boolean", @stack.last) if truth.nil?
400
+ return at + offset if truth == jump_on
401
+
402
+ @stack.pop
403
+ at + 1
404
+ end
405
+
406
+ def truth(value)
407
+ return true if true.equal?(value)
408
+
409
+ false if false.equal?(value) || value.nil? || UNDEFINED.equal?(value)
410
+ end
411
+
412
+ # A refused operand. When it is the undefined an unbound load pushed,
413
+ # the run reports the unbound variable instead, as the reference does.
414
+ def mismatch(operation, wanted, operand)
415
+ return unbound(@unbound) if UNDEFINED.equal?(operand) && @unbound
416
+
417
+ TypeMismatchError.new(reason: operation, message: "#{operation} expects #{wanted}")
418
+ end
419
+
420
+ def unbound(name) = UndefinedVariableError.new(reason: "unbound_variable", message: "Undefined variable: #{name}")
151
421
  end
152
422
  end
423
+ private_constant :Evaluator
153
424
  end
425
+
426
+ require_relative "evaluator/compare"
427
+ require_relative "evaluator/order"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Predicator
4
+ # How a Float is written as text where predicator writes a number into a
5
+ # string: the reference's own spelling of a float, which is its host
6
+ # language's shortest form. The shortest digits that read back as the same
7
+ # Float are placed in one of two forms:
8
+ #
9
+ # - plain, with a point and a `.0` when the number is whole: `1230.0`,
10
+ # `0.001`;
11
+ # - exponent, one digit, a point, at least one more digit, `e` and the
12
+ # exponent, signed only when negative: `1.0e3`, `1.5e-7`.
13
+ #
14
+ # At a magnitude of 2**53 or more the exponent form is written; below it,
15
+ # whichever of the two is shorter, and the plain form when they are the
16
+ # same length. A negative number carries a minus in front of either form;
17
+ # zero is `0.0` and negative zero `-0.0`.
18
+ #
19
+ # It also reads a number's decimal text as the Float nearest it ({read}).
20
+ #
21
+ # The module holds no state. It is private to the package.
22
+ module FloatText
23
+ # The magnitude from which the exponent form is always written.
24
+ EXPONENT_FROM = 2**53
25
+
26
+ # The magnitude from which a number rounds to no finite Float: halfway
27
+ # between the largest Float and 2**1024.
28
+ CEILING = (2**1024) - (2**970)
29
+
30
+ # The magnitude at or below which a number rounds to a zero Float: half
31
+ # the smallest Float above zero.
32
+ FLOOR = Rational(1, 2**1075)
33
+
34
+ module_function
35
+
36
+ # The Float nearest the number a decimal text writes (an optional minus,
37
+ # digits, and an optional point and digits; no exponent), or nil when
38
+ # the number is too large for a finite Float. A number too close to zero
39
+ # for any other Float reads as zero, signed as the text is. The
40
+ # magnitude is judged on the exact number first, so the host's parser is
41
+ # never asked for a Float outside its range, where it would print a
42
+ # warning.
43
+ #
44
+ # @param text [String]
45
+ # @return [Float, nil]
46
+ def read(text)
47
+ magnitude = Rational(text).abs
48
+ return if magnitude >= CEILING
49
+ return Float(text.start_with?("-") ? "-0" : "0") if magnitude <= FLOOR
50
+
51
+ Float(text)
52
+ end
53
+
54
+ # @param value [Float] a finite Float
55
+ # @return [String]
56
+ def spell(value)
57
+ sign = value.to_s.start_with?("-") ? "-" : ""
58
+ return "#{sign}0.0" if value.zero?
59
+
60
+ digits, point = shortest_digits(value.abs)
61
+ exponent = exponent_form(digits, point)
62
+ return sign + exponent if value.abs >= EXPONENT_FROM
63
+
64
+ plain = plain_form(digits, point)
65
+ sign + (plain.size <= exponent.size ? plain : exponent)
66
+ end
67
+
68
+ # The shortest significant digits of a positive Float, without leading or
69
+ # trailing zeros, and where the point falls among them: the Float is
70
+ # 0.DIGITS times ten to the power point. Ruby's own Float#to_s writes the
71
+ # same shortest digits, in a form of its own that this reads apart.
72
+ def shortest_digits(magnitude)
73
+ mantissa, exponent = magnitude.to_s.split("e")
74
+ whole, fraction = mantissa.split(".")
75
+ digits = whole + fraction
76
+ significant = digits.sub(/\A0+/, "")
77
+ point = whole.size + exponent.to_i - (digits.size - significant.size)
78
+ [significant.sub(/0+\z/, ""), point]
79
+ end
80
+
81
+ def plain_form(digits, point)
82
+ return "0.#{"0" * -point}#{digits}" if point <= 0
83
+ return "#{digits}#{"0" * (point - digits.size)}.0" if point >= digits.size
84
+
85
+ "#{digits[0, point]}.#{digits[point..]}"
86
+ end
87
+
88
+ def exponent_form(digits, point)
89
+ fraction = digits.size > 1 ? digits[1..] : "0"
90
+ "#{digits[0]}.#{fraction}e#{point - 1}"
91
+ end
92
+ end
93
+ private_constant :FloatText
94
+ end