dentaku 3.5.8 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +2 -4
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/CHANGELOG.md +74 -1
  6. data/Gemfile +1 -1
  7. data/README.md +87 -0
  8. data/dentaku.gemspec +15 -8
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +3 -4
  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 +2 -3
  21. data/lib/dentaku/ast/function.rb +15 -2
  22. data/lib/dentaku/ast/function_registry.rb +10 -1
  23. data/lib/dentaku/ast/functions/and.rb +4 -4
  24. data/lib/dentaku/ast/functions/avg.rb +2 -2
  25. data/lib/dentaku/ast/functions/duration.rb +1 -1
  26. data/lib/dentaku/ast/functions/if.rb +9 -1
  27. data/lib/dentaku/ast/functions/intercept.rb +2 -2
  28. data/lib/dentaku/ast/functions/mul.rb +2 -2
  29. data/lib/dentaku/ast/functions/or.rb +4 -4
  30. data/lib/dentaku/ast/functions/pluck.rb +1 -1
  31. data/lib/dentaku/ast/functions/string_functions.rb +1 -1
  32. data/lib/dentaku/ast/functions/sum.rb +2 -2
  33. data/lib/dentaku/ast/functions/xor.rb +4 -4
  34. data/lib/dentaku/ast/grouping.rb +6 -0
  35. data/lib/dentaku/ast/negation.rb +5 -0
  36. data/lib/dentaku/ast/node.rb +25 -0
  37. data/lib/dentaku/ast/operation.rb +7 -0
  38. data/lib/dentaku/bulk_expression_solver.rb +9 -10
  39. data/lib/dentaku/calculator.rb +45 -17
  40. data/lib/dentaku/date_arithmetic.rb +2 -2
  41. data/lib/dentaku/exceptions.rb +105 -13
  42. data/lib/dentaku/numeric_parser.rb +1 -1
  43. data/lib/dentaku/parser.rb +24 -48
  44. data/lib/dentaku/token_matcher.rb +31 -37
  45. data/lib/dentaku/token_matchers.rb +3 -3
  46. data/lib/dentaku/tokenizer.rb +1 -15
  47. data/lib/dentaku/version.rb +1 -1
  48. data/spec/ast/case_spec.rb +7 -0
  49. data/spec/bulk_expression_solver_spec.rb +13 -3
  50. data/spec/calculator_spec.rb +67 -0
  51. data/spec/exceptions_spec.rb +87 -0
  52. data/spec/gemspec_spec.rb +10 -0
  53. data/spec/identifiers_spec.rb +66 -0
  54. data/spec/parser_spec.rb +35 -0
  55. data/spec/print_visitor_spec.rb +5 -0
  56. data/spec/volatile_functions_spec.rb +121 -0
  57. metadata +21 -63
  58. data/.travis.yml +0 -10
@@ -8,7 +8,7 @@ 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
14
  @name = name.to_s
@@ -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
@@ -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,8 +5,8 @@ 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
12
  flatten_args.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(0, :+) / BigDecimal(flatten_args.length)
@@ -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,7 +12,7 @@ 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
 
@@ -4,8 +4,8 @@ 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
11
  args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(1, :*)
@@ -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
 
@@ -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
@@ -4,8 +4,8 @@ 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
11
  args.flatten.map { |arg| Dentaku::NumericParser.ensure_numeric!(arg) }.reduce(0, :+)
@@ -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,7 +78,7 @@ 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
@@ -106,7 +105,7 @@ module Dentaku
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,7 +119,7 @@ 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
125
  stored_name = normalized_name(var_name)
@@ -157,7 +156,7 @@ module Dentaku
157
156
  cache_key = "#{!!calculator.case_sensitive}|#{expressions.keys.map(&:to_s).sort.join("|")}"
158
157
  @ordered_deps ||= self.class.dependency_cache.fetch(cache_key) {
159
158
  DependencyResolver.find_resolve_order(dependencies, calculator.case_sensitive).tap do |d|
160
- self.class.dependency_cache[cache_key] = d if Dentaku.cache_dependency_order?
159
+ self.class.dependency_cache[cache_key] = d if calculator.cache_dependency_order?
161
160
  end
162
161
  }
163
162
  end
@@ -9,38 +9,46 @@ require 'dentaku/token'
9
9
  module Dentaku
10
10
  class Calculator
11
11
  include StringCasing
12
- attr_reader :result, :memory, :tokenizer, :case_sensitive, :aliases,
12
+ attr_reader :result, :memory, :tokenizer, :case_sensitive,
13
13
  :nested_data_support, :ast_cache, :raw_date_literals
14
14
 
15
- def initialize(options = {})
15
+ def initialize(case_sensitive: false, aliases: nil, nested_data_support: true,
16
+ raw_date_literals: true, ast_cache: {},
17
+ cache_ast: nil, cache_dependency_order: nil)
16
18
  clear
17
19
  @tokenizer = Tokenizer.new
18
- @case_sensitive = options.delete(:case_sensitive)
19
- @aliases = options.delete(:aliases) || Dentaku.aliases
20
- @nested_data_support = options.fetch(:nested_data_support, true)
21
- options.delete(:nested_data_support)
22
- @raw_date_literals = options.fetch(:raw_date_literals, true)
23
- options.delete(:raw_date_literals)
24
- @ast_cache = options
20
+ @case_sensitive = case_sensitive
21
+ @aliases = aliases
22
+ @nested_data_support = nested_data_support
23
+ @raw_date_literals = raw_date_literals
24
+ @ast_cache = ast_cache
25
+ @cache_ast = cache_ast
26
+ @cache_dependency_order = cache_dependency_order
25
27
  @disable_ast_cache = false
26
28
  @function_registry = Dentaku::AST::FunctionRegistry.new
27
29
  end
28
30
 
29
- def self.add_function(name, type, body, callback = nil)
30
- Dentaku::AST::FunctionRegistry.default.register(name, type, body, callback)
31
+ # explicitly configured aliases win; otherwise the module-level default is
32
+ # resolved lazily so it can be set after this calculator was created
33
+ def aliases
34
+ @aliases || Dentaku.aliases
35
+ end
36
+
37
+ def self.add_function(name, type, body, callback = nil, volatile: false)
38
+ Dentaku::AST::FunctionRegistry.default.register(name, type, body, callback, volatile: volatile)
31
39
  end
32
40
 
33
41
  def self.add_functions(functions)
34
- functions.each { |(name, type, body, callback)| add_function(name, type, body, callback) }
42
+ functions.each { |(name, type, body, callback, volatile)| add_function(name, type, body, callback, volatile: !!volatile) }
35
43
  end
36
44
 
37
- def add_function(name, type, body, callback = nil)
38
- @function_registry.register(name, type, body, callback)
45
+ def add_function(name, type, body, callback = nil, volatile: false)
46
+ @function_registry.register(name, type, body, callback, volatile: volatile)
39
47
  self
40
48
  end
41
49
 
42
50
  def add_functions(functions)
43
- functions.each { |(name, type, body, callback)| add_function(name, type, body, callback) }
51
+ functions.each { |(name, type, body, callback, volatile)| add_function(name, type, body, callback, volatile: !!volatile) }
44
52
  self
45
53
  end
46
54
 
@@ -56,7 +64,7 @@ module Dentaku
56
64
  return evaluate_array(expression, context, &block) if expression.is_a?(Array)
57
65
 
58
66
  evaluate!(expression, context)
59
- rescue Dentaku::Error, Dentaku::ArgumentError, Dentaku::ZeroDivisionError => ex
67
+ rescue Dentaku::Error => ex
60
68
  block.call(expression, ex) if block_given?
61
69
  end
62
70
 
@@ -106,6 +114,20 @@ module Dentaku
106
114
  end
107
115
  end
108
116
 
117
+ # every identifier the expression could reference, regardless of
118
+ # branching: purely syntactic, ignores stored memory, and never
119
+ # evaluates guards or functions
120
+ def identifiers(expression)
121
+ case expression
122
+ when Dentaku::AST::Node
123
+ expression.dependencies(AST::Node::STATIC_CONTEXT).uniq
124
+ when Array
125
+ expression.flat_map { |e| identifiers(e) }.uniq
126
+ else
127
+ ast(expression).dependencies(AST::Node::STATIC_CONTEXT).uniq
128
+ end
129
+ end
130
+
109
131
  def ast(expression)
110
132
  return expression if expression.is_a?(AST::Node)
111
133
  return expression.map { |e| ast(e) } if expression.is_a? Array
@@ -186,7 +208,13 @@ module Dentaku
186
208
  end
187
209
 
188
210
  def cache_ast?
189
- Dentaku.cache_ast? && !@disable_ast_cache
211
+ return false if @disable_ast_cache
212
+
213
+ @cache_ast.nil? ? Dentaku.cache_ast? : @cache_ast
214
+ end
215
+
216
+ def cache_dependency_order?
217
+ @cache_dependency_order.nil? ? Dentaku.cache_dependency_order? : @cache_dependency_order
190
218
  end
191
219
  end
192
220
  end
@@ -20,7 +20,7 @@ module Dentaku
20
20
  change_datetime(@base, duration.unit, duration.value)
21
21
  end
22
22
  else
23
- raise Dentaku::ArgumentError.for(:incompatible_type, value: duration, for: Numeric),
23
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: duration, expected: Numeric),
24
24
  "'#{duration || duration.class}' is not coercible for date arithmetic"
25
25
  end
26
26
  end
@@ -39,7 +39,7 @@ module Dentaku
39
39
  when Dentaku::TokenScanner::DATE_TIME_REGEXP
40
40
  @base - Time.parse(duration).to_datetime
41
41
  else
42
- raise Dentaku::ArgumentError.for(:incompatible_type, value: duration, for: Numeric),
42
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: duration, expected: Numeric),
43
43
  "'#{duration || duration.class}' is not coercible for date arithmetic"
44
44
  end
45
45
  end
@@ -1,9 +1,16 @@
1
1
  module Dentaku
2
- class Error < StandardError
3
- attr_accessor :recipient_variable
2
+ # Shared by all Dentaku exceptions so that `rescue Dentaku::Error` catches
3
+ # everything the gem raises, including exceptions that subclass Ruby
4
+ # built-ins (Dentaku::ArgumentError, Dentaku::ZeroDivisionError).
5
+ module Error
6
+ attr_accessor :assigned_to
4
7
  end
5
8
 
6
- class UnboundVariableError < Error
9
+ class BaseError < StandardError
10
+ include Error
11
+ end
12
+
13
+ class UnboundVariableError < BaseError
7
14
  attr_reader :unbound_variables
8
15
 
9
16
  def initialize(unbound_variables)
@@ -11,7 +18,7 @@ module Dentaku
11
18
  end
12
19
  end
13
20
 
14
- class MathDomainError < Error
21
+ class MathDomainError < BaseError
15
22
  attr_reader :function_name, :args
16
23
 
17
24
  def initialize(function_name, args)
@@ -20,22 +27,23 @@ module Dentaku
20
27
  end
21
28
  end
22
29
 
23
- class NodeError < Error
24
- attr_reader :child, :expect, :actual
30
+ class NodeError < BaseError
31
+ attr_reader :operand, :expected, :actual
25
32
 
26
- def initialize(expect, actual, child)
27
- @expect = Array(expect)
33
+ def initialize(expected, actual, operand)
34
+ @expected = Array(expected)
28
35
  @actual = actual
29
- @child = child
36
+ @operand = operand
30
37
  end
31
38
  end
32
39
 
33
- class ParseError < Error
40
+ class ParseError < BaseError
34
41
  attr_reader :reason, :meta
35
42
 
36
43
  def initialize(reason, **meta)
37
44
  @reason = reason
38
45
  @meta = meta
46
+ super(default_message)
39
47
  end
40
48
 
41
49
  private_class_method :new
@@ -54,14 +62,49 @@ module Dentaku
54
62
 
55
63
  new(reason, **meta)
56
64
  end
65
+
66
+ private
67
+
68
+ def default_message
69
+ case reason
70
+ when :node_invalid
71
+ if meta.key?(:operation)
72
+ expected = Array(meta[:expected]).map { |e| e == :incompatible ? :compatible : e }
73
+ "#{meta[:operation]} requires #{expected.join(', ')} operands, but got #{meta[:actual]}"
74
+ else
75
+ "Node is invalid"
76
+ end
77
+ when :too_few_operands
78
+ "#{meta[:operation]} has too few operands (given #{meta[:actual]}, expected #{meta[:expected]})"
79
+ when :too_many_operands
80
+ "#{meta[:operation]} has too many operands (given #{meta[:actual]}, expected #{meta[:expected]})"
81
+ when :undefined_function
82
+ "Undefined function #{meta[:function_name]}"
83
+ when :unprocessed_token
84
+ "Unprocessed token #{meta[:token_name]}"
85
+ when :unknown_case_token
86
+ "Unknown case token #{meta[:token_name]}"
87
+ when :unbalanced_bracket
88
+ "Unbalanced bracket"
89
+ when :unbalanced_parenthesis
90
+ "Unbalanced parenthesis"
91
+ when :unknown_grouping_token
92
+ "Unknown grouping token #{meta[:token_name]}"
93
+ when :not_implemented_token_category
94
+ "Not implemented for tokens of category #{meta[:token_category]}"
95
+ when :invalid_statement
96
+ "Invalid statement"
97
+ end
98
+ end
57
99
  end
58
100
 
59
- class TokenizerError < Error
101
+ class TokenizerError < BaseError
60
102
  attr_reader :reason, :meta
61
103
 
62
104
  def initialize(reason, **meta)
63
105
  @reason = reason
64
106
  @meta = meta
107
+ super(default_message)
65
108
  end
66
109
 
67
110
  private_class_method :new
@@ -80,15 +123,32 @@ module Dentaku
80
123
 
81
124
  new(reason, **meta)
82
125
  end
126
+
127
+ private
128
+
129
+ def default_message
130
+ case reason
131
+ when :parse_error
132
+ "parse error at: '#{meta[:at]}'"
133
+ when :too_many_opening_parentheses
134
+ "too many opening parentheses"
135
+ when :too_many_closing_parentheses
136
+ "too many closing parentheses"
137
+ when :unexpected_zero_width_match
138
+ "unexpected zero-width match (:#{meta[:token_category]}) at '#{meta[:at]}'"
139
+ end
140
+ end
83
141
  end
84
142
 
85
143
  class ArgumentError < ::ArgumentError
144
+ include Error
145
+
86
146
  attr_reader :reason, :meta
87
- attr_accessor :recipient_variable
88
147
 
89
148
  def initialize(reason, **meta)
90
149
  @reason = reason
91
150
  @meta = meta
151
+ super(default_message)
92
152
  end
93
153
 
94
154
  private_class_method :new
@@ -108,9 +168,41 @@ module Dentaku
108
168
 
109
169
  new(reason, **meta)
110
170
  end
171
+
172
+ private
173
+
174
+ def default_message
175
+ case reason
176
+ when :incompatible_type
177
+ if meta.key?(:function_name)
178
+ "#{meta[:function_name]}() requires #{meta[:expected]} arguments, but got #{meta[:actual].class}"
179
+ else
180
+ "#{meta[:actual].class} is not compatible with #{meta[:expected]}"
181
+ end
182
+ when :invalid_operator
183
+ "#{meta[:operation]} requires operands that respond to #{meta[:operator]}"
184
+ when :invalid_value
185
+ if meta.key?(:expected)
186
+ "'#{meta[:actual]}' is not a valid #{meta[:expected]}"
187
+ elsif meta.key?(:actual)
188
+ "'#{meta[:actual]}' is not a valid value"
189
+ else
190
+ "Invalid value"
191
+ end
192
+ when :too_few_arguments
193
+ "#{meta[:function_name]}() has too few arguments (given #{meta[:actual]}, expected #{expected_count})"
194
+ when :wrong_number_of_arguments
195
+ "#{meta[:function_name]}() has the wrong number of arguments (given #{meta[:actual]}, expected #{expected_count})"
196
+ end
197
+ end
198
+
199
+ def expected_count
200
+ expected = meta[:expected]
201
+ expected.is_a?(Range) && expected.end.nil? ? "at least #{expected.begin}" : expected
202
+ end
111
203
  end
112
204
 
113
205
  class ZeroDivisionError < ::ZeroDivisionError
114
- attr_accessor :recipient_variable
206
+ include Error
115
207
  end
116
208
  end
@@ -54,7 +54,7 @@ module Dentaku
54
54
  return number if number
55
55
  end
56
56
 
57
- raise Dentaku::ArgumentError.for(:incompatible_type, value: value, for: Numeric),
57
+ raise Dentaku::ArgumentError.for(:incompatible_type, actual: value, expected: Numeric),
58
58
  "'#{value || value.class}' is not coercible to numeric"
59
59
  end
60
60