dentaku 3.5.7 → 4.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +2 -6
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/CHANGELOG.md +91 -0
  6. data/Gemfile +14 -1
  7. data/README.md +87 -0
  8. data/dentaku.gemspec +14 -14
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +5 -17
  11. data/lib/dentaku/ast/array.rb +6 -0
  12. data/lib/dentaku/ast/bitwise.rb +6 -4
  13. data/lib/dentaku/ast/case/case_conditional.rb +6 -0
  14. data/lib/dentaku/ast/case/case_else.rb +6 -0
  15. data/lib/dentaku/ast/case/case_switch_variable.rb +6 -0
  16. data/lib/dentaku/ast/case/case_then.rb +6 -0
  17. data/lib/dentaku/ast/case/case_when.rb +7 -0
  18. data/lib/dentaku/ast/case.rb +30 -3
  19. data/lib/dentaku/ast/combinators.rb +49 -4
  20. data/lib/dentaku/ast/comparators.rb +3 -7
  21. data/lib/dentaku/ast/function.rb +12 -13
  22. data/lib/dentaku/ast/function_registry.rb +11 -2
  23. data/lib/dentaku/ast/functions/abs.rb +1 -1
  24. data/lib/dentaku/ast/functions/and.rb +4 -4
  25. data/lib/dentaku/ast/functions/avg.rb +3 -3
  26. data/lib/dentaku/ast/functions/duration.rb +1 -1
  27. data/lib/dentaku/ast/functions/if.rb +9 -1
  28. data/lib/dentaku/ast/functions/intercept.rb +4 -4
  29. data/lib/dentaku/ast/functions/max.rb +1 -1
  30. data/lib/dentaku/ast/functions/min.rb +1 -1
  31. data/lib/dentaku/ast/functions/mul.rb +3 -3
  32. data/lib/dentaku/ast/functions/or.rb +4 -4
  33. data/lib/dentaku/ast/functions/pluck.rb +1 -1
  34. data/lib/dentaku/ast/functions/round.rb +1 -1
  35. data/lib/dentaku/ast/functions/rounddown.rb +2 -2
  36. data/lib/dentaku/ast/functions/roundup.rb +2 -2
  37. data/lib/dentaku/ast/functions/ruby_math.rb +5 -2
  38. data/lib/dentaku/ast/functions/string_functions.rb +5 -5
  39. data/lib/dentaku/ast/functions/sum.rb +3 -3
  40. data/lib/dentaku/ast/functions/xor.rb +4 -4
  41. data/lib/dentaku/ast/grouping.rb +6 -0
  42. data/lib/dentaku/ast/negation.rb +5 -0
  43. data/lib/dentaku/ast/node.rb +25 -0
  44. data/lib/dentaku/ast/operation.rb +7 -0
  45. data/lib/dentaku/bulk_expression_solver.rb +22 -16
  46. data/lib/dentaku/calculator.rb +45 -17
  47. data/lib/dentaku/date_arithmetic.rb +2 -2
  48. data/lib/dentaku/dependency_resolver.rb +12 -5
  49. data/lib/dentaku/exceptions.rb +105 -13
  50. data/lib/dentaku/numeric_parser.rb +68 -0
  51. data/lib/dentaku/parser.rb +24 -48
  52. data/lib/dentaku/print_visitor.rb +1 -0
  53. data/lib/dentaku/token_matcher.rb +31 -37
  54. data/lib/dentaku/token_matchers.rb +3 -3
  55. data/lib/dentaku/token_scanner.rb +3 -4
  56. data/lib/dentaku/tokenizer.rb +2 -16
  57. data/lib/dentaku/version.rb +1 -1
  58. data/spec/ast/case_spec.rb +7 -0
  59. data/spec/ast/function_spec.rb +11 -25
  60. data/spec/bulk_expression_solver_spec.rb +48 -3
  61. data/spec/calculator_spec.rb +77 -0
  62. data/spec/dependency_resolver_spec.rb +14 -0
  63. data/spec/exceptions_spec.rb +87 -0
  64. data/spec/gemspec_spec.rb +10 -0
  65. data/spec/identifiers_spec.rb +66 -0
  66. data/spec/numeric_parser_spec.rb +66 -0
  67. data/spec/parser_spec.rb +35 -0
  68. data/spec/print_visitor_spec.rb +5 -0
  69. data/spec/tokenizer_spec.rb +6 -0
  70. data/spec/volatile_functions_spec.rb +121 -0
  71. metadata +27 -150
  72. data/.travis.yml +0 -10
@@ -21,24 +21,20 @@ module Dentaku
21
21
 
22
22
  l.public_send(operator, r)
23
23
  rescue ::ArgumentError, ::TypeError => e
24
- raise Dentaku::ArgumentError.for(:incompatible_type, value: r, for: l.class), e.message
24
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
25
25
  end
26
26
 
27
27
  private
28
28
 
29
29
  def cast(val)
30
30
  return val unless val.is_a?(::String)
31
- return val unless val.match?(Arithmetic::DECIMAL) || val.match?(Arithmetic::INTEGER)
32
31
 
33
- v = BigDecimal(val, Float::DIG + 1)
34
- v = v.to_i if v.frac.zero?
35
- v
32
+ Dentaku::NumericParser.parse_string(val) || val
36
33
  end
37
34
 
38
35
  def validate_value(value)
39
36
  unless value.respond_to?(operator)
40
- raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator),
41
- "#{ self.class } requires operands that respond to #{operator}"
37
+ raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
42
38
  end
43
39
 
44
40
  value
@@ -22,12 +22,19 @@ module Dentaku
22
22
  .flat_map { |a, _| a.dependencies(context) }
23
23
  end
24
24
 
25
+ # volatile functions may not be evaluated during dependency
26
+ # resolution; registry-generated classes override this per the
27
+ # volatile: option passed at registration
28
+ def self.volatile?
29
+ false
30
+ end
31
+
25
32
  def self.get(name)
26
33
  registry.get(name)
27
34
  end
28
35
 
29
- def self.register(name, type, implementation)
30
- registry.register(name, type, implementation)
36
+ def self.register(name, type, implementation, volatile: false)
37
+ registry.register(name, type, implementation, volatile: volatile)
31
38
  end
32
39
 
33
40
  def self.register_class(name, function_class)
@@ -38,18 +45,10 @@ module Dentaku
38
45
  @registry ||= FunctionRegistry.new
39
46
  end
40
47
 
41
- # @return [Numeric] where possible it returns an Integer otherwise a BigDecimal.
42
- # An Exception will be raised if a value is passed that cannot be cast to a Number.
43
- def self.numeric(value)
44
- return value if value.is_a?(::Numeric)
45
-
46
- if value.is_a?(::String)
47
- number = value[/\A-?\d*\.?\d+\z/]
48
- return number.include?('.') ? BigDecimal(number, DIG) : number.to_i if number
49
- end
48
+ private
50
49
 
51
- raise Dentaku::ArgumentError.for(:incompatible_type, value: value, for: Numeric),
52
- "'#{value || value.class}' is not coercible to numeric"
50
+ def compute_pure?
51
+ !self.class.volatile? && args.all?(&:pure?)
53
52
  end
54
53
  end
55
54
  end
@@ -8,10 +8,10 @@ module Dentaku
8
8
  nil
9
9
  end
10
10
 
11
- def register(name, type, implementation, callback = nil)
11
+ def register(name, type, implementation, callback = nil, volatile: false)
12
12
  function = Class.new(Function) do
13
13
  def self.name=(name)
14
- @name = name
14
+ @name = name.to_s
15
15
  end
16
16
 
17
17
  def self.name
@@ -42,6 +42,14 @@ module Dentaku
42
42
  @callback
43
43
  end
44
44
 
45
+ def self.volatile=(volatile)
46
+ @volatile = volatile
47
+ end
48
+
49
+ def self.volatile?
50
+ @volatile
51
+ end
52
+
45
53
  def self.arity
46
54
  @implementation.arity < 0 ? nil : @implementation.arity
47
55
  end
@@ -70,6 +78,7 @@ module Dentaku
70
78
  function.type = type
71
79
  function.implementation = implementation
72
80
  function.callback = callback
81
+ function.volatile = volatile
73
82
 
74
83
  self[function_name(name)] = function
75
84
  end
@@ -1,5 +1,5 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:abs, :numeric, lambda { |numeric|
4
- Dentaku::AST::Function.numeric(numeric).abs
4
+ Dentaku::NumericParser.ensure_numeric!(numeric).abs
5
5
  })
@@ -5,8 +5,8 @@ Dentaku::AST::Function.register(:and, :logical, lambda { |*args|
5
5
  if args.empty?
6
6
  raise Dentaku::ArgumentError.for(
7
7
  :too_few_arguments,
8
- function_name: 'AND()', at_least: 1, given: 0
9
- ), 'AND() requires at least one argument'
8
+ function_name: 'AND', expected: 1.., actual: 0
9
+ )
10
10
  end
11
11
 
12
12
  args.all? do |arg|
@@ -18,8 +18,8 @@ Dentaku::AST::Function.register(:and, :logical, lambda { |*args|
18
18
  else
19
19
  raise Dentaku::ArgumentError.for(
20
20
  :incompatible_type,
21
- function_name: 'AND()', expect: :logical, actual: arg.class
22
- ), 'AND() requires arguments to be logical expressions'
21
+ function_name: 'AND', expected: :logical, actual: arg
22
+ )
23
23
  end
24
24
  end
25
25
  })
@@ -5,9 +5,9 @@ Dentaku::AST::Function.register(:avg, :numeric, ->(*args) {
5
5
  if flatten_args.empty?
6
6
  raise Dentaku::ArgumentError.for(
7
7
  :too_few_arguments,
8
- function_name: 'AVG()', at_least: 1, given: 0
9
- ), 'AVG() requires at least one argument'
8
+ function_name: 'AVG', expected: 1.., actual: 0
9
+ )
10
10
  end
11
11
 
12
- flatten_args.map { |arg| Dentaku::AST::Function.numeric(arg) }.reduce(0, :+) / BigDecimal(flatten_args.length)
12
+ flatten_args.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(0, :+) / BigDecimal(flatten_args.length)
13
13
  })
@@ -25,7 +25,7 @@ module Dentaku
25
25
  when /months?/ then :month
26
26
  when /days?/ then :day
27
27
  else
28
- raise Dentaku::ArgumentError.for(:incompatible_type, value: unit, for: Duration),
28
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: unit, expected: Duration),
29
29
  "'#{unit || unit.class}' is not a valid duration unit"
30
30
  end
31
31
  end
@@ -36,8 +36,16 @@ module Dentaku
36
36
  end
37
37
 
38
38
  def dependencies(context = {})
39
+ return all_arg_dependencies(context) if static_mode?(context) || !predicate.pure?
40
+
39
41
  predicate.value(context) ? left.dependencies(context) : right.dependencies(context)
40
- rescue Dentaku::Error, Dentaku::ArgumentError, Dentaku::ZeroDivisionError
42
+ rescue Dentaku::Error
43
+ all_arg_dependencies(context)
44
+ end
45
+
46
+ private
47
+
48
+ def all_arg_dependencies(context)
41
49
  args.flat_map { |arg| arg.dependencies(context) }.uniq
42
50
  end
43
51
  end
@@ -4,7 +4,7 @@ Dentaku::AST::Function.register(:intercept, :list, ->(*args) {
4
4
  if args.length != 2
5
5
  raise Dentaku::ArgumentError.for(
6
6
  :wrong_number_of_arguments,
7
- function_name: 'INTERCEPT()', exact: 2, given: args.length
7
+ function_name: 'INTERCEPT', expected: 2, actual: args.length
8
8
  ), 'INTERCEPT() requires exactly two arrays of numbers'
9
9
  end
10
10
 
@@ -12,13 +12,13 @@ Dentaku::AST::Function.register(:intercept, :list, ->(*args) {
12
12
  if !x_values.is_a?(Array) || !y_values.is_a?(Array) || x_values.length != y_values.length
13
13
  raise Dentaku::ArgumentError.for(
14
14
  :invalid_value,
15
- function_name: 'INTERCEPT()'
15
+ function_name: 'INTERCEPT'
16
16
  ), 'INTERCEPT() requires arrays of equal length'
17
17
  end
18
18
 
19
19
  n = x_values.length.to_f
20
- x_values = x_values.map { |arg| Dentaku::AST::Function.numeric(arg) }
21
- y_values = y_values.map { |arg| Dentaku::AST::Function.numeric(arg) }
20
+ x_values = x_values.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }
21
+ y_values = y_values.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }
22
22
 
23
23
  x_avg = x_values.sum / n
24
24
  y_avg = y_values.sum / n
@@ -1,5 +1,5 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:max, :numeric, ->(*args) {
4
- args.flatten.map { |arg| Dentaku::AST::Function.numeric(arg) }.max
4
+ args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.max
5
5
  })
@@ -1,5 +1,5 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:min, :numeric, ->(*args) {
4
- args.flatten.map { |arg| Dentaku::AST::Function.numeric(arg) }.min
4
+ args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.min
5
5
  })
@@ -4,9 +4,9 @@ Dentaku::AST::Function.register(:mul, :numeric, ->(*args) {
4
4
  if args.empty?
5
5
  raise Dentaku::ArgumentError.for(
6
6
  :too_few_arguments,
7
- function_name: 'MUL()', at_least: 1, given: 0
8
- ), 'MUL() requires at least one argument'
7
+ function_name: 'MUL', expected: 1.., actual: 0
8
+ )
9
9
  end
10
10
 
11
- args.flatten.map { |arg| Dentaku::AST::Function.numeric(arg) }.reduce(1, :*)
11
+ args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(1, :*)
12
12
  })
@@ -5,8 +5,8 @@ Dentaku::AST::Function.register(:or, :logical, lambda { |*args|
5
5
  if args.empty?
6
6
  raise Dentaku::ArgumentError.for(
7
7
  :too_few_arguments,
8
- function_name: 'OR()', at_least: 1, given: 0
9
- ), 'OR() requires at least one argument'
8
+ function_name: 'OR', expected: 1.., actual: 0
9
+ )
10
10
  end
11
11
 
12
12
  args.any? do |arg|
@@ -18,8 +18,8 @@ Dentaku::AST::Function.register(:or, :logical, lambda { |*args|
18
18
  else
19
19
  raise Dentaku::ArgumentError.for(
20
20
  :incompatible_type,
21
- function_name: 'OR()', expect: :logical, actual: arg.class
22
- ), 'OR() requires arguments to be logical expressions'
21
+ function_name: 'OR', expected: :logical, actual: arg
22
+ )
23
23
  end
24
24
  end
25
25
  })
@@ -16,7 +16,7 @@ module Dentaku
16
16
  collection = Array(@args[0].value(context))
17
17
 
18
18
  unless collection.all? { |elem| elem.is_a?(Hash) }
19
- raise ArgumentError.for(:incompatible_type, value: collection),
19
+ raise ArgumentError.for(:incompatible_type, actual: collection),
20
20
  'PLUCK() requires first argument to be an array of hashes'
21
21
  end
22
22
 
@@ -1,5 +1,5 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:round, :numeric, lambda { |numeric, places = 0|
4
- Dentaku::AST::Function.numeric(numeric).round(Dentaku::AST::Function.numeric(places || 0).to_i)
4
+ Dentaku::NumericParser.ensure_numeric!(numeric).round(Dentaku::NumericParser.ensure_numeric!(places || 0).to_i)
5
5
  })
@@ -1,8 +1,8 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:rounddown, :numeric, lambda { |numeric, precision = 0|
4
- precision = Dentaku::AST::Function.numeric(precision || 0).to_i
4
+ precision = Dentaku::NumericParser.ensure_numeric!(precision || 0).to_i
5
5
  tens = 10.0**precision
6
- result = (Dentaku::AST::Function.numeric(numeric) * tens).floor / tens
6
+ result = (Dentaku::NumericParser.ensure_numeric!(numeric) * tens).floor / tens
7
7
  precision <= 0 ? result.to_i : result
8
8
  })
@@ -1,8 +1,8 @@
1
1
  require_relative '../function'
2
2
 
3
3
  Dentaku::AST::Function.register(:roundup, :numeric, lambda { |numeric, precision = 0|
4
- precision = Dentaku::AST::Function.numeric(precision || 0).to_i
4
+ precision = Dentaku::NumericParser.ensure_numeric!(precision || 0).to_i
5
5
  tens = 10.0**precision
6
- result = (Dentaku::AST::Function.numeric(numeric) * tens).ceil / tens
6
+ result = (Dentaku::NumericParser.ensure_numeric!(numeric) * tens).ceil / tens
7
7
  precision <= 0 ? result.to_i : result
8
8
  })
@@ -16,8 +16,11 @@ module Dentaku
16
16
  @implementation = Math.method(method)
17
17
  end
18
18
 
19
+ # Ruby documents Module#name as a String (or nil for anonymous
20
+ # classes), so expose one even though the function is registered
21
+ # under a Symbol
19
22
  def self.name
20
- @name
23
+ @name.to_s
21
24
  end
22
25
 
23
26
  def self.arity
@@ -39,7 +42,7 @@ module Dentaku
39
42
  end
40
43
 
41
44
  def value(context = {})
42
- args = @args.flatten.map { |a| Dentaku::AST::Function.numeric(a.value(context)) }
45
+ args = @args.flatten.map { |a|Dentaku::NumericParser.ensure_numeric!(a.value(context)) }
43
46
  self.class.call(*args)
44
47
  end
45
48
 
@@ -11,7 +11,7 @@ module Dentaku
11
11
  def negative_argument_failure(fun, arg = 'length')
12
12
  raise Dentaku::ArgumentError.for(
13
13
  :invalid_value,
14
- function_name: "#{fun}()"
14
+ function_name: fun
15
15
  ), "#{fun}() requires #{arg} to be positive"
16
16
  end
17
17
  end
@@ -32,7 +32,7 @@ module Dentaku
32
32
 
33
33
  def value(context = {})
34
34
  string = @string.value(context).to_s
35
- length = Dentaku::AST::Function.numeric(@length.value(context)).to_i
35
+ length = Dentaku::NumericParser.ensure_numeric!(@length.value(context)).to_i
36
36
  negative_argument_failure('LEFT') if length < 0
37
37
  string[0, length]
38
38
  end
@@ -54,7 +54,7 @@ module Dentaku
54
54
 
55
55
  def value(context = {})
56
56
  string = @string.value(context).to_s
57
- length = Dentaku::AST::Function.numeric(@length.value(context)).to_i
57
+ length = Dentaku::NumericParser.ensure_numeric!(@length.value(context)).to_i
58
58
  negative_argument_failure('RIGHT') if length < 0
59
59
  string[length * -1, length] || string
60
60
  end
@@ -76,9 +76,9 @@ module Dentaku
76
76
 
77
77
  def value(context = {})
78
78
  string = @string.value(context).to_s
79
- offset = Dentaku::AST::Function.numeric(@offset.value(context)).to_i
79
+ offset = Dentaku::NumericParser.ensure_numeric!(@offset.value(context)).to_i
80
80
  negative_argument_failure('MID', 'offset') if offset < 0
81
- length = Dentaku::AST::Function.numeric(@length.value(context)).to_i
81
+ length = Dentaku::NumericParser.ensure_numeric!(@length.value(context)).to_i
82
82
  negative_argument_failure('MID') if length < 0
83
83
  string[offset - 1, length].to_s
84
84
  end
@@ -4,9 +4,9 @@ Dentaku::AST::Function.register(:sum, :numeric, ->(*args) {
4
4
  if args.empty?
5
5
  raise Dentaku::ArgumentError.for(
6
6
  :too_few_arguments,
7
- function_name: 'SUM()', at_least: 1, given: 0
8
- ), 'SUM() requires at least one argument'
7
+ function_name: 'SUM', expected: 1.., actual: 0
8
+ )
9
9
  end
10
10
 
11
- args.flatten.map { |arg| Dentaku::AST::Function.numeric(arg) }.reduce(0, :+)
11
+ args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(0, :+)
12
12
  })
@@ -16,8 +16,8 @@ module Dentaku
16
16
  if @args.empty?
17
17
  raise Dentaku::ArgumentError.for(
18
18
  :too_few_arguments,
19
- function_name: 'XOR()', at_least: 1, given: 0
20
- ), 'XOR() requires at least one argument'
19
+ function_name: 'XOR', expected: 1.., actual: 0
20
+ )
21
21
  end
22
22
 
23
23
  true_arg_count = 0
@@ -31,8 +31,8 @@ module Dentaku
31
31
  else
32
32
  raise Dentaku::ArgumentError.for(
33
33
  :incompatible_type,
34
- function_name: 'XOR()', expect: :logical, actual: arg.class
35
- ), 'XOR() requires arguments to be logical expressions'
34
+ function_name: 'XOR', expected: :logical, actual: arg
35
+ )
36
36
  end
37
37
  end
38
38
  true_arg_count == 1
@@ -18,6 +18,12 @@ module Dentaku
18
18
  def dependencies(context = {})
19
19
  @node.dependencies(context)
20
20
  end
21
+
22
+ private
23
+
24
+ def compute_pure?
25
+ @node.pure?
26
+ end
21
27
  end
22
28
  end
23
29
  end
@@ -46,6 +46,11 @@ module Dentaku
46
46
 
47
47
  private
48
48
 
49
+ # bypasses Operation's binary initialize, so left/right are unset
50
+ def compute_pure?
51
+ @node.pure?
52
+ end
53
+
49
54
  def valid_node?(node)
50
55
  node && (node.dependencies.any? || node.type == :numeric)
51
56
  end
@@ -1,6 +1,12 @@
1
1
  module Dentaku
2
2
  module AST
3
3
  class Node
4
+ # reserved context key (double-underscore prefix, like
5
+ # __evaluation_mode) that switches dependencies() into static mode:
6
+ # guards are never evaluated and every branch is reported
7
+ STATIC_MODE_KEY = "__static_dependencies".freeze
8
+ STATIC_CONTEXT = { STATIC_MODE_KEY => true }.freeze
9
+
4
10
  def self.precedence
5
11
  0
6
12
  end
@@ -17,6 +23,15 @@ module Dentaku
17
23
  []
18
24
  end
19
25
 
26
+ # whether this subtree may be evaluated during dependency resolution
27
+ # without running volatile user code; a property of the parsed AST,
28
+ # computed once and memoized
29
+ def pure?
30
+ return @pure if defined?(@pure)
31
+
32
+ @pure = compute_pure?
33
+ end
34
+
20
35
  def type
21
36
  nil
22
37
  end
@@ -24,6 +39,16 @@ module Dentaku
24
39
  def name
25
40
  self.class.name.to_s.split("::").last.upcase
26
41
  end
42
+
43
+ private
44
+
45
+ def static_mode?(context)
46
+ context[STATIC_MODE_KEY] == true
47
+ end
48
+
49
+ def compute_pure?
50
+ true
51
+ end
27
52
  end
28
53
  end
29
54
  end
@@ -39,6 +39,13 @@ module Dentaku
39
39
  def operator_spacing
40
40
  " "
41
41
  end
42
+
43
+ private
44
+
45
+ # compact: unary operations (e.g. Percentage) only populate left
46
+ def compute_pure?
47
+ [left, right].compact.all?(&:pure?)
48
+ end
42
49
  end
43
50
  end
44
51
  end
@@ -11,8 +11,8 @@ module Dentaku
11
11
  @calculator = calculator
12
12
  end
13
13
 
14
- def evaluate(*args)
15
- @calculator.evaluate!(*args)
14
+ def evaluate(*args, &block)
15
+ @calculator.evaluate!(*args, &block)
16
16
  end
17
17
  end
18
18
 
@@ -22,10 +22,9 @@ module Dentaku
22
22
  @block = block || ->(*) { :undefined }
23
23
  end
24
24
 
25
- def evaluate(*args)
26
- @calculator.evaluate(*args) { |expr, ex|
27
- @block.call(ex)
28
- }
25
+ def evaluate(*args, &block)
26
+ handler = block || ->(_expr, ex) { @block.call(ex) }
27
+ @calculator.evaluate(*args, &handler)
29
28
  end
30
29
  end
31
30
 
@@ -79,13 +78,13 @@ module Dentaku
79
78
 
80
79
  def expression_with_exception_handler(var_name, &block)
81
80
  ->(_expr, ex) {
82
- ex.recipient_variable = var_name
81
+ ex.assigned_to = var_name
83
82
  block.call(ex)
84
83
  }
85
84
  end
86
85
 
87
86
  def load_results(&block)
88
- facts, _formulas = expressions.transform_keys(&:downcase)
87
+ facts, _formulas = expressions.transform_keys { |k| normalized_name(k) }
89
88
  .transform_values { |v| calculator.ast(v) }
90
89
  .partition { |_, v| calculator.dependencies(v, nil).empty? }
91
90
 
@@ -101,12 +100,12 @@ module Dentaku
101
100
  next if expressions[var_name].nil?
102
101
 
103
102
  with_rescues(var_name, results, block) do
104
- results[var_name] = evaluated_facts[var_name] || evaluator.evaluate(
103
+ results[var_name] = evaluated_facts[normalized_name(var_name)] || evaluator.evaluate(
105
104
  expressions[var_name],
106
105
  context.merge(results),
107
106
  &expression_with_exception_handler(var_name, &block)
108
107
  ).tap { |res|
109
- res.recipient_variable = var_name if res.respond_to?(:recipient_variable=)
108
+ res.assigned_to = var_name if res.respond_to?(:assigned_to=)
110
109
  res
111
110
  }
112
111
  end
@@ -120,14 +119,21 @@ module Dentaku
120
119
  def with_rescues(var_name, results, block)
121
120
  yield
122
121
  rescue Dentaku::UnboundVariableError, Dentaku::ZeroDivisionError, Dentaku::ArgumentError => ex
123
- ex.recipient_variable = var_name
122
+ ex.assigned_to = var_name
124
123
  results[var_name] = block.call(ex)
125
124
  ensure
126
- if results[var_name] == :undefined && calculator.memory.has_key?(var_name.downcase)
127
- results[var_name] = calculator.memory[var_name.downcase]
125
+ stored_name = normalized_name(var_name)
126
+ if results[var_name] == :undefined && calculator.memory.has_key?(stored_name)
127
+ results[var_name] = calculator.memory[stored_name]
128
128
  end
129
129
  end
130
130
 
131
+ # names are normalized the same way the calculator stores them, so that
132
+ # facts and memory lookups line up with the identifiers the parser emits
133
+ def normalized_name(variable_name)
134
+ calculator.standardize_case(variable_name.to_s)
135
+ end
136
+
131
137
  def expressions
132
138
  @expressions ||= Hash[expression_hash.map { |k, v| [k.to_s, v] }]
133
139
  end
@@ -147,10 +153,10 @@ module Dentaku
147
153
  end
148
154
 
149
155
  def variables_in_resolve_order
150
- cache_key = expressions.keys.map(&:to_s).sort.join("|")
156
+ cache_key = "#{!!calculator.case_sensitive}|#{expressions.keys.map(&:to_s).sort.join("|")}"
151
157
  @ordered_deps ||= self.class.dependency_cache.fetch(cache_key) {
152
- DependencyResolver.find_resolve_order(dependencies).tap do |d|
153
- self.class.dependency_cache[cache_key] = d if Dentaku.cache_dependency_order?
158
+ DependencyResolver.find_resolve_order(dependencies, calculator.case_sensitive).tap do |d|
159
+ self.class.dependency_cache[cache_key] = d if calculator.cache_dependency_order?
154
160
  end
155
161
  }
156
162
  end