fhirpath 0.2.0.pre1

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.
@@ -0,0 +1,690 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module FHIRPath
6
+ class Evaluator
7
+ def evaluate(node, context)
8
+ evaluate_node(node, context)
9
+ rescue Error => e
10
+ raise e if e.expression
11
+
12
+ raise e.class.new(
13
+ e.message,
14
+ code: e.code,
15
+ span: e.span,
16
+ expression: context.options[:expression],
17
+ cause: e.original_cause
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def evaluate_node(node, context)
24
+ case node
25
+ when AST::Literal
26
+ Collection.new([node.value])
27
+ when AST::CollectionLiteral
28
+ Collection.new(node.elements.flat_map { |element| evaluate(element, context).items })
29
+ when AST::Identifier
30
+ identifier(node, context)
31
+ when AST::Variable
32
+ variable(node, context)
33
+ when AST::ExternalConstant
34
+ external_constant(node, context)
35
+ when AST::MemberInvocation
36
+ navigate(node, context)
37
+ when AST::Indexer
38
+ index(node, context)
39
+ when AST::FunctionInvocation
40
+ function(node, context)
41
+ when AST::UnaryExpression
42
+ unary(node, context)
43
+ when AST::BinaryExpression
44
+ binary(node, context)
45
+ else
46
+ raise EvaluationError.new("unsupported AST node: #{node.class}", code: :unsupported_ast)
47
+ end
48
+ end
49
+
50
+ def variable(node, context)
51
+ value = case node.name
52
+ when 'this'
53
+ context.focus
54
+ when 'index'
55
+ context.index.nil? ? Collection.empty : Collection.new([context.index])
56
+ when 'total'
57
+ context.total.nil? ? Collection.empty : Collection.new([context.total])
58
+ else
59
+ unless context.variables.key?(node.name)
60
+ raise EvaluationError.new("unknown variable: $#{node.name}",
61
+ code: :unknown_variable, span: node.span)
62
+ end
63
+ Collection.from(context.variables[node.name])
64
+ end
65
+ Collection.from(value)
66
+ end
67
+
68
+ def external_constant(node, context)
69
+ value = if context.variables.key?(node.name)
70
+ context.variables[node.name]
71
+ else
72
+ context.host.constant(node.name, context: context)
73
+ end
74
+ Collection.from(value)
75
+ rescue UnknownConstantError, HostError => e
76
+ raise e.class.new(
77
+ e.message,
78
+ code: e.code,
79
+ span: node.span,
80
+ cause: e.original_cause
81
+ )
82
+ end
83
+
84
+ def identifier(node, context)
85
+ values = []
86
+ model_types = []
87
+ context.focus.items.each do |item|
88
+ if context.model.root_type(item).to_s == node.name
89
+ values << item
90
+ model_types << nil
91
+ else
92
+ append_property(values, model_types, item, node.name, context)
93
+ end
94
+ end
95
+ Collection.new(values, types: model_types)
96
+ rescue NoMethodError => e
97
+ raise ModelError.new("cannot read #{node.name}", code: :model_navigation,
98
+ span: node.span, cause: e)
99
+ end
100
+
101
+ def navigate(node, context)
102
+ receiver = evaluate(node.receiver, context)
103
+ values = []
104
+ model_types = []
105
+ receiver.items.each do |item|
106
+ append_property(values, model_types, item, node.name, context)
107
+ end
108
+ Collection.new(values, types: model_types)
109
+ rescue NoMethodError => e
110
+ raise ModelError.new("cannot read #{node.name}", code: :model_navigation,
111
+ span: node.span, cause: e)
112
+ end
113
+
114
+ # Resolves one property and records, per produced item, the logical type
115
+ # name the model declares for the resolved value (nil when the model has no
116
+ # metadata for this property, e.g. PlainModel or a non-choice field).
117
+ def append_property(values, model_types, item, logical_name, context)
118
+ produced = Collection.from(context.model.property(item, logical_name)).items
119
+ type = context.model.property_logical_type(item, logical_name)
120
+ values.concat(produced)
121
+ model_types.concat(Array.new(produced.length, type))
122
+ end
123
+
124
+ def index(node, context)
125
+ values = evaluate(node.receiver, context)
126
+ return Collection.empty if values.empty?
127
+
128
+ index_values = evaluate(node.index, context)
129
+ return Collection.empty if index_values.empty?
130
+
131
+ position = require_singleton(index_values, node.index.span)
132
+ unless position.is_a?(::Integer)
133
+ raise TypeError.new('index must be an integer', code: :expected_integer_index,
134
+ span: node.index.span)
135
+ end
136
+ return Collection.empty if position.negative? || position >= values.count
137
+
138
+ Collection.new([values.items[position]])
139
+ end
140
+
141
+ def function(node, context)
142
+ spec = context.functions.find(node.name)
143
+ validate_function!(node, spec)
144
+ receiver = node.receiver ? evaluate(node.receiver, context) : context.focus
145
+ invoke_function(node, receiver, context, spec)
146
+ end
147
+
148
+ def validate_function!(node, spec)
149
+ unless spec
150
+ raise UnknownFunctionError.new(
151
+ "unknown function: #{node.name}", code: :unknown_function, span: node.span
152
+ )
153
+ end
154
+ return if valid_arity?(spec.arity, node.arguments.length)
155
+
156
+ raise EvaluationError.new(
157
+ "wrong number of arguments for #{node.name}", code: :invalid_arity, span: node.span
158
+ )
159
+ end
160
+
161
+ def invoke_function(node, receiver, context, spec)
162
+ case node.name
163
+ when 'where'
164
+ filter(receiver, node.arguments.first, context)
165
+ when 'select'
166
+ select(receiver, node.arguments.first, context)
167
+ when 'first'
168
+ receiver.empty? ? Collection.empty : Collection.new([receiver.first_item])
169
+ when 'last'
170
+ receiver.empty? ? Collection.empty : Collection.new([receiver.items.last])
171
+ when 'tail'
172
+ receiver.empty? ? Collection.empty : Collection.new(receiver.items[1..])
173
+ when 'take'
174
+ take(receiver, integer_argument(node, context))
175
+ when 'skip'
176
+ skip(receiver, integer_argument(node, context))
177
+ when 'exists'
178
+ exists(receiver, node.arguments.first, context)
179
+ when 'count'
180
+ Collection.new([receiver.count])
181
+ when 'sum'
182
+ sum(receiver, node)
183
+ when 'avg'
184
+ average(receiver, node)
185
+ when 'max', 'min'
186
+ extremum(receiver, node.name, node)
187
+ when 'empty'
188
+ Collection.new([receiver.empty?])
189
+ when 'not'
190
+ negate(receiver, node)
191
+ when 'all'
192
+ all(receiver, node.arguments.first, context)
193
+ when 'allTrue', 'anyTrue', 'allFalse', 'anyFalse'
194
+ boolean_aggregate(receiver, node.name, node)
195
+ else
196
+ invoke_registered(node, receiver, context, spec)
197
+ end
198
+ end
199
+
200
+ def exists(receiver, expression, context)
201
+ return Collection.new([!receiver.empty?]) unless expression
202
+
203
+ Collection.new([!filter(receiver, expression, context).empty?])
204
+ end
205
+
206
+ def negate(receiver, node)
207
+ return Collection.empty if receiver.empty?
208
+
209
+ value = require_singleton(receiver, node.receiver ? node.receiver.span : node.span)
210
+ unless [true, false].include?(value)
211
+ raise TypeError.new('expected a Boolean value', code: :expected_boolean, span: node.span)
212
+ end
213
+
214
+ Collection.new([!value])
215
+ end
216
+
217
+ def integer_argument(node, context)
218
+ argument = node.arguments.first
219
+ value = evaluate(argument, context)
220
+ count = require_singleton(value, argument.span)
221
+ unless count.is_a?(::Integer)
222
+ raise TypeError.new('expected an integer argument', code: :expected_integer, span: argument.span)
223
+ end
224
+
225
+ count
226
+ end
227
+
228
+ def take(receiver, count)
229
+ return Collection.empty if receiver.empty? || count <= 0
230
+ return Collection.new(receiver.items) if count >= receiver.count
231
+
232
+ Collection.new(receiver.items.first(count))
233
+ end
234
+
235
+ def skip(receiver, count)
236
+ return Collection.new(receiver.items) if count <= 0
237
+ return Collection.empty if count >= receiver.count
238
+
239
+ Collection.new(receiver.items[count..])
240
+ end
241
+
242
+ # Aggregates (FHIRPath 2.0.0 count(); FHIRPath 3.0.0 STU3 sum/avg/max/min).
243
+ # Each returns a fresh immutable collection and never mutates the input.
244
+ # sum()/avg() promote Integer and Decimal items through BigDecimal when any
245
+ # decimal participates (matching the evaluator's numeric promotion in
246
+ # arithmetic and equality); max()/min() reuse the comparison operator
247
+ # semantics so numerics compare across Integer/Decimal and strings order
248
+ # lexicographically, with incompatible item types raising a TypeError.
249
+ def sum(receiver, node)
250
+ return Collection.empty if receiver.empty?
251
+ unless receiver.items.all? { |item| numeric?(item) }
252
+ raise TypeError.new('sum requires numeric items', code: :expected_number, span: node.span)
253
+ end
254
+
255
+ result = if receiver.items.all?(::Integer)
256
+ receiver.items.reduce(:+)
257
+ else
258
+ receiver.items.reduce { |total, item| decimal(total) + decimal(item) }
259
+ end
260
+ Collection.new([result])
261
+ end
262
+
263
+ def average(receiver, node)
264
+ return Collection.empty if receiver.empty?
265
+ unless receiver.items.all? { |item| numeric?(item) }
266
+ raise TypeError.new('avg requires numeric items', code: :expected_number, span: node.span)
267
+ end
268
+
269
+ total = receiver.items.reduce { |sum, item| decimal(sum) + decimal(item) }
270
+ Collection.new([decimal(total) / receiver.count])
271
+ end
272
+
273
+ def extremum(receiver, mode, node)
274
+ return Collection.empty if receiver.empty?
275
+
276
+ result = receiver.items.first
277
+ receiver.items.drop(1).each do |item|
278
+ comparison = compare_values(result, item, node.span)
279
+ result = item if mode == 'max' ? comparison.negative? : comparison.positive?
280
+ end
281
+ Collection.new([result])
282
+ end
283
+
284
+ def all(receiver, predicate, context)
285
+ result = receiver.items.each_with_index.all? do |item, position|
286
+ predicate_result = evaluate(
287
+ predicate,
288
+ context.derive(focus: Collection.new([item]), index: position, total: receiver.count)
289
+ )
290
+ boolean_value(predicate_result, predicate.span) == true
291
+ end
292
+ Collection.new([result])
293
+ end
294
+
295
+ def boolean_aggregate(receiver, name, node)
296
+ values = receiver.items.map do |value|
297
+ unless [true, false].include?(value)
298
+ raise TypeError.new('expected a Boolean value', code: :expected_boolean, span: node.span)
299
+ end
300
+
301
+ value
302
+ end
303
+ result = case name
304
+ when 'allTrue' then values.all?
305
+ when 'anyTrue' then values.any?
306
+ when 'allFalse' then values.all?(&:!)
307
+ when 'anyFalse' then values.any?(&:!)
308
+ end
309
+ Collection.new([result])
310
+ end
311
+
312
+ def invoke_registered(node, receiver, context, spec)
313
+ unless spec.implementation
314
+ raise UnsupportedFeatureError.new(
315
+ "function #{node.name} has no implementation", code: :unsupported_function,
316
+ span: node.span
317
+ )
318
+ end
319
+
320
+ arguments = node.arguments.each_with_index.map do |argument, position|
321
+ parameter = spec.parameters[position]
322
+ delayed = parameter.nil? ? spec.delayed : parameter == :expression
323
+ delayed ? argument : evaluate(argument, context)
324
+ end
325
+ Collection.from(spec.implementation.call(receiver, arguments, context))
326
+ end
327
+
328
+ def filter(receiver, predicate, context)
329
+ matching = receiver.items.each_with_index.with_object([]) do |(item, position), result|
330
+ predicate_result = evaluate(
331
+ predicate,
332
+ context.derive(focus: Collection.new([item]), index: position, total: receiver.count)
333
+ )
334
+ result << item if truthy?(predicate_result, predicate.span)
335
+ end
336
+ Collection.new(matching)
337
+ end
338
+
339
+ def select(receiver, expression, context)
340
+ Collection.new(receiver.items.each_with_index.flat_map do |item, position|
341
+ evaluate(
342
+ expression,
343
+ context.derive(focus: Collection.new([item]), index: position, total: receiver.count)
344
+ ).items
345
+ end)
346
+ end
347
+
348
+ def valid_arity?(arity, actual)
349
+ arity.is_a?(Range) ? arity.cover?(actual) : arity.nil? || arity == actual
350
+ end
351
+
352
+ def unary(node, context)
353
+ value = evaluate(node.operand, context)
354
+ return Collection.empty if value.empty?
355
+
356
+ number = require_singleton(value, node.operand.span)
357
+ unless numeric?(number)
358
+ raise TypeError.new('unary arithmetic requires a number', code: :expected_number,
359
+ span: node.span)
360
+ end
361
+ result = node.operator == :minus ? -number : number
362
+ Collection.new([result])
363
+ end
364
+
365
+ def binary(node, context)
366
+ case node.operator
367
+ when :equals, :not_equals, :equivalent, :not_equivalent
368
+ equality(node, context)
369
+ when :and, :or, :xor, :implies
370
+ logic(node, context)
371
+ when :plus, :minus, :multiply, :divide, :div, :mod
372
+ arithmetic(node, context)
373
+ when :concatenate
374
+ concatenate(node, context)
375
+ when :union
376
+ union(node, context)
377
+ when :in, :contains
378
+ membership(node, context)
379
+ when :is, :as
380
+ type_operator(node, context)
381
+ when :less_than, :less_or_equal, :greater_than, :greater_or_equal
382
+ comparison(node, context)
383
+ else
384
+ raise UnsupportedFeatureError.new("operator #{node.operator} is not supported",
385
+ code: :unsupported_operator, span: node.span)
386
+ end
387
+ end
388
+
389
+ def equality(node, context)
390
+ left = evaluate(node.left, context)
391
+ right = evaluate(node.right, context)
392
+ equivalent = %i[equivalent not_equivalent].include?(node.operator)
393
+
394
+ return Collection.new([node.operator == :equivalent]) if equivalent && left.empty? && right.empty?
395
+ return Collection.new([node.operator == :not_equivalent]) if equivalent && (left.empty? || right.empty?)
396
+ return Collection.empty if left.empty? || right.empty?
397
+
398
+ equal = if equivalent
399
+ collection_equivalent?(left, right)
400
+ else
401
+ collection_equal?(left, right)
402
+ end
403
+ equal = !equal if %i[not_equals not_equivalent].include?(node.operator)
404
+ Collection.new([equal])
405
+ end
406
+
407
+ def collection_equal?(left, right)
408
+ return false unless left.count == right.count
409
+
410
+ left.items.zip(right.items).all? { |a, b| equal?(a, b) }
411
+ end
412
+
413
+ def collection_equivalent?(left, right)
414
+ return false unless left.count == right.count
415
+
416
+ remaining = right.items.dup
417
+ left.items.all? do |left_value|
418
+ match = remaining.index { |right_value| equivalent?(left_value, right_value) }
419
+ next false unless match
420
+
421
+ remaining.delete_at(match)
422
+ true
423
+ end
424
+ end
425
+
426
+ def logic(node, context)
427
+ left = boolean_value(evaluate(node.left, context), node.left.span)
428
+ right = boolean_value(evaluate(node.right, context), node.right.span)
429
+ Collection.from(logical_result(node.operator, left, right))
430
+ end
431
+
432
+ def logical_result(operator, left, right)
433
+ case operator
434
+ when :and
435
+ return false if [left, right].include?(false)
436
+ return nil if left.nil? || right.nil?
437
+
438
+ true
439
+ when :or
440
+ return true if [left, right].include?(true)
441
+ return nil if left.nil? || right.nil?
442
+
443
+ false
444
+ when :xor
445
+ return nil if left.nil? || right.nil?
446
+
447
+ left != right
448
+ when :implies
449
+ return true if left == false || right == true
450
+ return nil if left.nil? || right.nil?
451
+
452
+ false
453
+ end
454
+ end
455
+
456
+ def arithmetic(node, context)
457
+ left = evaluate(node.left, context)
458
+ right = evaluate(node.right, context)
459
+ return Collection.empty if left.empty? || right.empty?
460
+
461
+ left_value = require_singleton(left, node.left.span)
462
+ right_value = require_singleton(right, node.right.span)
463
+ if left_value.is_a?(::String) && right_value.is_a?(::String) && node.operator == :plus
464
+ return Collection.new([left_value + right_value])
465
+ end
466
+
467
+ unless numeric?(left_value) && numeric?(right_value)
468
+ raise TypeError.new('arithmetic requires numeric values', code: :expected_number,
469
+ span: node.span)
470
+ end
471
+
472
+ # Only `/`, `div`, `mod` divide. A zero divisor yields an EMPTY collection
473
+ # (FHIRPath 2.0.0, arithmetic operators); it is never an error. `+`, `-`,
474
+ # `*` operate on zero normally, so there is no blanket zero guard here.
475
+ result = case node.operator
476
+ when :plus then left_value + right_value
477
+ when :minus then left_value - right_value
478
+ when :multiply then left_value * right_value
479
+ when :divide
480
+ return Collection.empty if zero?(right_value)
481
+
482
+ decimal(left_value) / decimal(right_value)
483
+ when :div
484
+ return Collection.empty if zero?(right_value)
485
+
486
+ (decimal(left_value) / decimal(right_value)).truncate
487
+ when :mod
488
+ return Collection.empty if zero?(right_value)
489
+
490
+ remainder(left_value, right_value)
491
+ end
492
+ Collection.new([result])
493
+ rescue ::ZeroDivisionError
494
+ Collection.empty
495
+ end
496
+
497
+ def comparison(node, context)
498
+ left = evaluate(node.left, context)
499
+ right = evaluate(node.right, context)
500
+ return Collection.empty if left.empty? || right.empty?
501
+
502
+ left_value = require_singleton(left, node.left.span)
503
+ right_value = require_singleton(right, node.right.span)
504
+ comparison = compare_values(left_value, right_value, node.span)
505
+ result = case node.operator
506
+ when :less_than then comparison.negative?
507
+ when :less_or_equal then comparison <= 0
508
+ when :greater_than then comparison.positive?
509
+ when :greater_or_equal then comparison >= 0
510
+ end
511
+ Collection.new([result])
512
+ end
513
+
514
+ def concatenate(node, context)
515
+ left = evaluate(node.left, context)
516
+ right = evaluate(node.right, context)
517
+ left_value = left.empty? ? '' : require_singleton(left, node.left.span)
518
+ right_value = right.empty? ? '' : require_singleton(right, node.right.span)
519
+ unless left_value.is_a?(::String) && right_value.is_a?(::String)
520
+ raise TypeError.new('concatenation requires strings', code: :expected_string, span: node.span)
521
+ end
522
+
523
+ Collection.new([left_value + right_value])
524
+ end
525
+
526
+ def union(node, context)
527
+ left = evaluate(node.left, context)
528
+ right = evaluate(node.right, context)
529
+ # Union eliminates duplicate values from BOTH operands (FHIRPath 2.0.0,
530
+ # 5.4.1 union), using the `=` (Equals) predicate rather than Ruby value
531
+ # identity so that e.g. Integer 1 and Decimal 1.0 collapse to one value.
532
+ # First-seen order is preserved; operands are not mutated.
533
+ result = []
534
+ (left.items + right.items).each do |candidate|
535
+ result << candidate unless result.any? { |existing| equal?(existing, candidate) }
536
+ end
537
+ Collection.new(result)
538
+ end
539
+
540
+ def membership(node, context)
541
+ left = evaluate(node.left, context)
542
+ right = evaluate(node.right, context)
543
+ if node.operator == :in
544
+ return Collection.empty if left.empty?
545
+
546
+ value = require_singleton(left, node.left.span)
547
+ return Collection.new([false]) if right.empty?
548
+
549
+ return Collection.new([right.items.any? { |candidate| equal?(value, candidate) }])
550
+ end
551
+
552
+ # `contains`: the right operand is the value being searched for and must be
553
+ # a singleton (FHIRPath 2.0.0 membership operators). This still holds when
554
+ # the left (searched) collection is empty, so the cardinality check is done
555
+ # before the empty-left short-circuit. An empty right operand yields an
556
+ # empty result.
557
+ return Collection.empty if right.empty?
558
+
559
+ value = require_singleton(right, node.right.span)
560
+ return Collection.new([false]) if left.empty?
561
+
562
+ Collection.new([left.items.any? { |candidate| equal?(candidate, value) }])
563
+ end
564
+
565
+ def type_operator(node, context)
566
+ left = evaluate(node.left, context)
567
+ type_name = type_name_from(node.right, node.span)
568
+ return Collection.empty if left.empty?
569
+
570
+ value = require_singleton(left, node.left.span)
571
+ matches = logical_type?(value, type_name) || model_logical_type?(left, type_name)
572
+ return Collection.new([matches]) if node.operator == :is
573
+
574
+ matches ? Collection.new([value]) : Collection.empty
575
+ end
576
+
577
+ def type_name_from(node, span)
578
+ return node.name.downcase if node.is_a?(AST::Identifier)
579
+
580
+ raise TypeError.new('type operator requires a type name', code: :expected_type, span: span)
581
+ end
582
+
583
+ # FHIR model type recorded when the item was produced by model navigation
584
+ # (e.g. `Quantity` for a resolved `Observation.valueQuantity` choice). The
585
+ # FHIR type name is compared case-insensitively because `type_name_from`
586
+ # downcases the identifier on the right of `is`/`as`.
587
+ def model_logical_type?(collection, type_name)
588
+ recorded = collection.types&.first
589
+ !recorded.nil? && recorded.downcase == type_name
590
+ end
591
+
592
+ def logical_type?(value, type_name)
593
+ case type_name
594
+ when 'any' then true
595
+ when 'boolean' then [true, false].include?(value)
596
+ when 'integer' then value.is_a?(::Integer)
597
+ when 'decimal' then value.is_a?(BigDecimal) || (value.is_a?(::Float) && value.finite?)
598
+ when 'number' then numeric?(value)
599
+ when 'string' then value.is_a?(::String)
600
+ else false
601
+ end
602
+ end
603
+
604
+ def compare_values(left, right, span)
605
+ if numeric?(left) && numeric?(right)
606
+ decimal(left) <=> decimal(right)
607
+ elsif left.is_a?(::String) && right.is_a?(::String)
608
+ left <=> right
609
+ else
610
+ raise TypeError.new('comparison requires compatible values', code: :incompatible_comparison,
611
+ span: span)
612
+ end
613
+ end
614
+
615
+ def equal?(left, right)
616
+ return decimal(left) == decimal(right) if numeric?(left) && numeric?(right)
617
+ return false unless left.class == right.class
618
+
619
+ left == right
620
+ end
621
+
622
+ def equivalent?(left, right)
623
+ if left.is_a?(::String) && right.is_a?(::String)
624
+ normalize_string(left) == normalize_string(right)
625
+ elsif numeric?(left) && numeric?(right)
626
+ decimal_equivalent?(left, right)
627
+ else
628
+ equal?(left, right)
629
+ end
630
+ end
631
+
632
+ def normalize_string(value)
633
+ value.strip.downcase.gsub(/\s+/, ' ')
634
+ end
635
+
636
+ def decimal_equivalent?(left, right)
637
+ precision = [decimal_places(left), decimal_places(right)].min
638
+ decimal(left).round(precision) == decimal(right).round(precision)
639
+ end
640
+
641
+ def decimal_places(value)
642
+ text = decimal(value).to_s('F')
643
+ fraction = text.split('.', 2)[1]
644
+ fraction ? fraction.sub(/0+\z/, '').length : 0
645
+ end
646
+
647
+ def numeric?(value)
648
+ value.is_a?(::Integer) ||
649
+ value.is_a?(BigDecimal) ||
650
+ (value.is_a?(::Float) && value.finite?)
651
+ end
652
+
653
+ def decimal(value)
654
+ # Finite Float (e.g. from JSON.parse) is a Decimal in FHIRPath; convert it
655
+ # losslessly without mutating the source value. NaN/Infinity never reach
656
+ # here because numeric? rejects them.
657
+ value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
658
+ end
659
+
660
+ def remainder(left, right)
661
+ quotient = (decimal(left) / decimal(right)).truncate
662
+ result = left - (quotient * right)
663
+ left.is_a?(BigDecimal) || right.is_a?(BigDecimal) ? decimal(result) : result
664
+ end
665
+
666
+ def zero?(value)
667
+ value.zero?
668
+ end
669
+
670
+ def require_singleton(collection, span)
671
+ return collection.first_item if collection.singleton?
672
+
673
+ raise SingletonError.new('a singleton value was required', code: :singleton_required,
674
+ span: span)
675
+ end
676
+
677
+ def truthy?(collection, span)
678
+ boolean_value(collection, span) == true
679
+ end
680
+
681
+ def boolean_value(collection, span)
682
+ return nil if collection.empty?
683
+
684
+ value = require_singleton(collection, span)
685
+ return value if [true, false].include?(value)
686
+
687
+ raise TypeError.new('expected a Boolean value', code: :expected_boolean, span: span)
688
+ end
689
+ end
690
+ end