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
@@ -41,41 +41,42 @@ module Dentaku
41
41
  end
42
42
 
43
43
  def consume(count = 2)
44
- operator = operations.pop
45
- fail! :invalid_statement if operator.nil?
44
+ operation = operations.pop
45
+ fail! :invalid_statement if operation.nil?
46
46
 
47
47
  output_size = output.length
48
- args_size = operator.arity || count
49
- min_size = operator.arity || operator.min_param_count || count
50
- max_size = operator.arity || operator.max_param_count || count
48
+ args_size = operation.arity || count
49
+ min_size = operation.arity || operation.min_param_count || count
50
+ max_size = operation.arity || operation.max_param_count || count
51
51
 
52
52
  if output_size < min_size || args_size < min_size
53
- expect = min_size == max_size ? min_size : min_size..max_size
54
- fail! :too_few_operands, operator: operator, expect: expect, actual: output_size
53
+ expected = min_size == max_size ? min_size : min_size..max_size
54
+ fail! :too_few_operands, operation: operation, expected: expected, actual: output_size
55
55
  end
56
56
 
57
57
  if (output_size > max_size && operations.empty?) || args_size > max_size
58
- expect = min_size == max_size ? min_size : min_size..max_size
59
- fail! :too_many_operands, operator: operator, expect: expect, actual: output_size
58
+ expected = min_size == max_size ? min_size : min_size..max_size
59
+ fail! :too_many_operands, operation: operation, expected: expected, actual: output_size
60
60
  end
61
61
 
62
62
  args = []
63
- if operator == AST::Array && output.empty?
63
+ if operation == AST::Array && output.empty?
64
64
  # special case: empty array literal '{}'
65
- output.push(operator.new)
65
+ output.push(operation.new)
66
66
  else
67
67
  fail! :invalid_statement if output_size < args_size
68
68
  args = Array.new(args_size) { output.pop }.reverse
69
- output.push operator.new(*args)
69
+ output.push operation.new(*args)
70
70
  end
71
71
 
72
- if operator.respond_to?(:callback) && !operator.callback.nil?
73
- operator.callback.call(args)
72
+ if operation.respond_to?(:callback) && !operation.callback.nil?
73
+ operation.callback.call(args)
74
74
  end
75
75
  rescue ::ArgumentError => e
76
- raise Dentaku::ArgumentError, e.message
76
+ raise if e.is_a?(Dentaku::ArgumentError)
77
+ raise Dentaku::ArgumentError.for(:invalid_value), e.message
77
78
  rescue NodeError => e
78
- fail! :node_invalid, operator: operator, child: e.child, expect: e.expect, actual: e.actual
79
+ raise ParseError.for(:node_invalid, operation: operation, operand: e.operand, expected: e.expected, actual: e.actual), e.message
79
80
  end
80
81
 
81
82
  def parse
@@ -190,8 +191,11 @@ module Dentaku
190
191
  operations.push(AST::CaseConditional)
191
192
  consume(2)
192
193
  arities[-1] += 1
193
- elsif operations.last == AST::Case
194
- # First WHEN: finalize switch variable expression.
194
+ elsif operations[token_index].nil? ||
195
+ (operations[token_index] != AST::CaseWhen && operations[token_index] < AST::Operation)
196
+ # First WHEN: fold any operators pending from an unparenthesized
197
+ # switch expression, then finalize the switch variable.
198
+ consume_until(AST::Case)
195
199
  operations.push(AST::CaseSwitchVariable)
196
200
  consume
197
201
  end
@@ -268,7 +272,7 @@ module Dentaku
268
272
  when :close
269
273
  consume while operations.any? && operations.last != AST::Grouping
270
274
  lparen = operations.pop
271
- fail! :unbalanced_parenthesis, token unless lparen == AST::Grouping
275
+ fail! :unbalanced_parenthesis, token: token unless lparen == AST::Grouping
272
276
  if operations.last && operations.last < AST::Function
273
277
  consume(arities.pop.succ)
274
278
  end
@@ -284,35 +288,7 @@ module Dentaku
284
288
  end
285
289
 
286
290
  def fail!(reason, **meta)
287
- message =
288
- case reason
289
- when :node_invalid
290
- "#{meta.fetch(:operator)} requires #{meta.fetch(:expect).join(', ')} operands, but got #{meta.fetch(:actual)}"
291
- when :too_few_operands
292
- "#{meta.fetch(:operator)} has too few operands (given #{meta.fetch(:actual)}, expected #{meta.fetch(:expect)})"
293
- when :too_many_operands
294
- "#{meta.fetch(:operator)} has too many operands (given #{meta.fetch(:actual)}, expected #{meta.fetch(:expect)})"
295
- when :undefined_function
296
- "Undefined function #{meta.fetch(:function_name)}"
297
- when :unprocessed_token
298
- "Unprocessed token #{meta.fetch(:token_name)}"
299
- when :unknown_case_token
300
- "Unknown case token #{meta.fetch(:token_name)}"
301
- when :unbalanced_bracket
302
- "Unbalanced bracket"
303
- when :unbalanced_parenthesis
304
- "Unbalanced parenthesis"
305
- when :unknown_grouping_token
306
- "Unknown grouping token #{meta.fetch(:token_name)}"
307
- when :not_implemented_token_category
308
- "Not implemented for tokens of category #{meta.fetch(:token_category)}"
309
- when :invalid_statement
310
- "Invalid statement"
311
- else
312
- raise ::ArgumentError, "Unhandled #{reason}"
313
- end
314
-
315
- raise ParseError.for(reason, **meta), message
291
+ raise ParseError.for(reason, **meta)
316
292
  end
317
293
  end
318
294
  end
@@ -93,46 +93,40 @@ module Dentaku
93
93
  @values.empty? || @values.key?(value)
94
94
  end
95
95
 
96
- def self.datetime; new(:datetime); end
97
- def self.numeric; new(:numeric); end
98
- def self.string; new(:string); end
99
- def self.logical; new(:logical); end
96
+ def self.datetime = new(:datetime)
97
+ def self.numeric = new(:numeric)
98
+ def self.string = new(:string)
99
+ def self.logical = new(:logical)
100
100
  def self.value
101
101
  new(:datetime) | new(:numeric) | new(:string) | new(:logical)
102
102
  end
103
103
 
104
- def self.addsub; new(:operator, [:add, :subtract]); end
105
- def self.subtract; new(:operator, :subtract); end
106
- def self.anchored_minus; new(:operator, :subtract).caret; end
107
- def self.muldiv; new(:operator, [:multiply, :divide]); end
108
- def self.pow; new(:operator, :pow); end
109
- def self.mod; new(:operator, :mod); end
110
- def self.combinator; new(:combinator); end
111
-
112
- def self.comparator; new(:comparator); end
113
- def self.comp_gt; new(:comparator, [:gt, :ge]); end
114
- def self.comp_lt; new(:comparator, [:lt, :le]); end
115
-
116
- def self.open; new(:grouping, :open); end
117
- def self.close; new(:grouping, :close); end
118
- def self.comma; new(:grouping, :comma); end
119
- def self.non_group; new(:grouping).invert; end
120
- def self.non_group_star; new(:grouping).invert.star; end
121
- def self.non_close_plus; new(:grouping, :close).invert.plus; end
122
- def self.arguments; (value | comma).plus; end
123
-
124
- def self.if; new(:function, :if); end
125
- def self.round; new(:function, :round); end
126
- def self.roundup; new(:function, :roundup); end
127
- def self.rounddown; new(:function, :rounddown); end
128
- def self.not; new(:function, :not); end
129
-
130
- def self.method_missing(name, *args, &block)
131
- new(:function, name)
132
- end
133
-
134
- def self.respond_to_missing?(name, include_priv)
135
- true
136
- end
104
+ def self.addsub = new(:operator, [:add, :subtract])
105
+ def self.subtract = new(:operator, :subtract)
106
+ def self.anchored_minus = new(:operator, :subtract).caret
107
+ def self.muldiv = new(:operator, [:multiply, :divide])
108
+ def self.pow = new(:operator, :pow)
109
+ def self.mod = new(:operator, :mod)
110
+ def self.combinator = new(:combinator)
111
+
112
+ def self.comparator = new(:comparator)
113
+ def self.comp_gt = new(:comparator, [:gt, :ge])
114
+ def self.comp_lt = new(:comparator, [:lt, :le])
115
+
116
+ def self.open = new(:grouping, :open)
117
+ def self.close = new(:grouping, :close)
118
+ def self.comma = new(:grouping, :comma)
119
+ def self.non_group = new(:grouping).invert
120
+ def self.non_group_star = new(:grouping).invert.star
121
+ def self.non_close_plus = new(:grouping, :close).invert.plus
122
+ def self.arguments = (value | comma).plus
123
+
124
+ def self.function(name) = new(:function, name)
125
+
126
+ def self.if = function(:if)
127
+ def self.round = function(:round)
128
+ def self.roundup = function(:roundup)
129
+ def self.rounddown = function(:rounddown)
130
+ def self.not = function(:not)
137
131
  end
138
132
  end
@@ -6,7 +6,7 @@ module Dentaku
6
6
 
7
7
  def self.function_token_matchers(function_name, *symbols)
8
8
  token_matchers(:open, *symbols, :close).unshift(
9
- TokenMatcher.send(function_name)
9
+ TokenMatcher.function(function_name)
10
10
  )
11
11
  end
12
12
 
@@ -16,9 +16,9 @@ module Dentaku
16
16
  :comparator, :comp_gt, :comp_lt, :open, :close, :comma,
17
17
  :non_close_plus, :non_group, :non_group_star, :arguments,
18
18
  :logical, :combinator, :if, :round, :roundup, :rounddown, :not,
19
- :anchored_minus, :math_neg_pow, :math_neg_mul
19
+ :anchored_minus
20
20
  ].each_with_object({}) do |name, matchers|
21
- matchers[name] = TokenMatcher.send(name)
21
+ matchers[name] = TokenMatcher.public_send(name)
22
22
  end
23
23
 
24
24
  @matchers.fetch(symbol) do
@@ -94,21 +94,7 @@ module Dentaku
94
94
  end
95
95
 
96
96
  def fail!(reason, **meta)
97
- message =
98
- case reason
99
- when :parse_error
100
- "parse error at: '#{meta.fetch(:at)}'"
101
- when :too_many_opening_parentheses
102
- "too many opening parentheses"
103
- when :too_many_closing_parentheses
104
- "too many closing parentheses"
105
- when :unexpected_zero_width_match
106
- "unexpected zero-width match (:#{meta.fetch(:category)}) at '#{meta.fetch(:at)}'"
107
- else
108
- raise ::ArgumentError, "Unhandled #{reason}"
109
- end
110
-
111
- raise TokenizerError.for(reason, **meta), message
97
+ raise TokenizerError.for(reason, **meta)
112
98
  end
113
99
  end
114
100
  end
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "3.5.8"
2
+ VERSION = "4.0.0"
3
3
  end
@@ -80,5 +80,12 @@ describe Dentaku::AST::Case do
80
80
  node = described_class.new(switch, conditional1, conditional2, else2)
81
81
  expect(node.dependencies).to eq([:fruit, :tax, :fallback])
82
82
  end
83
+
84
+ it 'short-circuits to the matched branch when the switch is resolvable' do
85
+ node = described_class.new(switch, conditional1, conditional2, else2)
86
+ expect(node.dependencies(fruit: 'apple')).to eq([])
87
+ expect(node.dependencies(fruit: 'banana')).to eq([:tax])
88
+ expect(node.dependencies(fruit: 'orange')).to eq([:fallback])
89
+ end
83
90
  end
84
91
  end
@@ -195,7 +195,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
195
195
  described_class.new(expressions, calculator).solve do |ex|
196
196
  exception = ex
197
197
  end
198
- expect(exception.recipient_variable).to eq('more_apples')
198
+ expect(exception.assigned_to).to eq('more_apples')
199
199
  end
200
200
 
201
201
  it 'stores the recipient variable on the exception when there is an unbound variable' do
@@ -204,7 +204,17 @@ RSpec.describe Dentaku::BulkExpressionSolver do
204
204
  described_class.new(expressions, calculator).solve do |ex|
205
205
  exception = ex
206
206
  end
207
- expect(exception.recipient_variable).to eq('more_apples')
207
+ expect(exception.assigned_to).to eq('more_apples')
208
+ end
209
+
210
+ it 'sets the recipient variable before the solve block is invoked' do
211
+ expressions = {more_apples: "apples + 1"}
212
+ seen = :unset
213
+ described_class.new(expressions, calculator).solve do |ex|
214
+ seen = ex.assigned_to
215
+ :err
216
+ end
217
+ expect(seen).to eq('more_apples')
208
218
  end
209
219
 
210
220
  it 'stores the recipient variable on the exception when there is an ArgumentError' do
@@ -213,7 +223,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
213
223
  described_class.new(expressions, calculator).solve do |ex|
214
224
  exception = ex
215
225
  end
216
- expect(exception.recipient_variable).to eq('more_apples')
226
+ expect(exception.assigned_to).to eq('more_apples')
217
227
  end
218
228
 
219
229
  it 'safely handles argument errors' do
@@ -195,6 +195,21 @@ describe Dentaku::Calculator do
195
195
  expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
196
196
  end
197
197
 
198
+ it 'accesses into strings and nested arrays' do
199
+ expect(calculator.evaluate!('a[1]', a: 'abc')).to eq('b')
200
+ expect(calculator.evaluate!('a[1][0]', a: [[1], [2, 3]])).to eq(2)
201
+ end
202
+
203
+ it 'raises an argument error when accessing into a non-indexable value' do
204
+ expect { calculator.evaluate!('a[0]', a: 5) }.to raise_error(Dentaku::ArgumentError)
205
+ expect { calculator.evaluate!('a[0]', a: nil) }.to raise_error(Dentaku::ArgumentError)
206
+ expect { calculator.evaluate!('a[0]', a: true) }.to raise_error(Dentaku::ArgumentError)
207
+ end
208
+
209
+ it 'raises an argument error when the index type is incompatible' do
210
+ expect { calculator.evaluate!("a['one']", a: [1, 2, 3]) }.to raise_error(Dentaku::ArgumentError)
211
+ end
212
+
198
213
  it 'evaluates arrays' do
199
214
  expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3])
200
215
  expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3])
@@ -967,6 +982,25 @@ describe Dentaku::Calculator do
967
982
  end
968
983
  end
969
984
 
985
+ describe 'logical operators with unbound operands' do
986
+ it 'short-circuits OR when the bound operand is true' do
987
+ expect(calculator.evaluate!('a OR b', a: true)).to eq(true)
988
+ expect(calculator.evaluate!('a OR b', b: true)).to eq(true)
989
+ end
990
+
991
+ it 'short-circuits AND when the bound operand is false' do
992
+ expect(calculator.evaluate!('a AND b', a: false)).to eq(false)
993
+ expect(calculator.evaluate!('a AND b', b: false)).to eq(false)
994
+ end
995
+
996
+ it 'still raises when the unbound operand is needed to decide' do
997
+ expect { calculator.evaluate!('a OR b', a: false) }
998
+ .to raise_error(Dentaku::UnboundVariableError)
999
+ expect { calculator.evaluate!('a AND b', a: true) }
1000
+ .to raise_error(Dentaku::UnboundVariableError)
1001
+ end
1002
+ end
1003
+
970
1004
  describe 'aliases' do
971
1005
  it 'accepts aliases as instance option' do
972
1006
  expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
@@ -975,6 +1009,39 @@ describe Dentaku::Calculator do
975
1009
  it 'accepts aliases with whitespace before parentheses' do
976
1010
  expect(with_aliases.evaluate('rrround (5.1)')).to eq(5)
977
1011
  end
1012
+
1013
+ it 'applies module-level aliases set after calculator creation' do
1014
+ Dentaku.aliases = { round: ['redondear'] }
1015
+ expect(calculator.evaluate('redondear(5.1)')).to eq(5)
1016
+ ensure
1017
+ Dentaku.aliases = {}
1018
+ end
1019
+ end
1020
+
1021
+ describe 'constructor options' do
1022
+ it 'raises on unknown options' do
1023
+ expect { described_class.new(case_sensitve: true) }
1024
+ .to raise_error(::ArgumentError, /unknown keyword/)
1025
+ end
1026
+
1027
+ it 'accepts a pre-warmed AST cache' do
1028
+ node = calculator.ast('1+1')
1029
+ warmed = described_class.new(ast_cache: { '1+1' => node })
1030
+ expect(warmed.ast_cache).to eq('1+1' => node)
1031
+ end
1032
+
1033
+ it 'caches ASTs when caching is enabled on the instance' do
1034
+ caching = described_class.new(cache_ast: true)
1035
+ caching.ast('1+1')
1036
+ expect(caching.ast_cache.keys).to eq(['1+1'])
1037
+ end
1038
+
1039
+ it 'does not cache ASTs when disabled on the instance, even if enabled globally' do
1040
+ allow(Dentaku).to receive(:cache_ast?) { true }
1041
+ non_caching = described_class.new(cache_ast: false)
1042
+ non_caching.ast('1+1')
1043
+ expect(non_caching.ast_cache).to be_empty
1044
+ end
978
1045
  end
979
1046
 
980
1047
  describe 'nested_data' do
@@ -7,3 +7,90 @@ describe Dentaku::UnboundVariableError do
7
7
  expect(exception.unbound_variables).to include('length')
8
8
  end
9
9
  end
10
+
11
+ describe Dentaku::Error do
12
+ it 'is included in every Dentaku exception, even those subclassing Ruby built-ins' do
13
+ [
14
+ Dentaku::UnboundVariableError.new(['x']),
15
+ Dentaku::MathDomainError.new(:asin, [2]),
16
+ Dentaku::NodeError.new(:numeric, :string, :left),
17
+ Dentaku::ParseError.for(:node_invalid),
18
+ Dentaku::TokenizerError.for(:parse_error),
19
+ Dentaku::ArgumentError.for(:invalid_value),
20
+ Dentaku::ZeroDivisionError.new,
21
+ ].each do |exception|
22
+ expect(exception).to be_a(described_class)
23
+ end
24
+ end
25
+
26
+ it 'preserves stdlib ancestry for exceptions subclassing Ruby built-ins' do
27
+ expect(Dentaku::ArgumentError.for(:invalid_value)).to be_a(::ArgumentError)
28
+ expect(Dentaku::ZeroDivisionError.new).to be_a(::ZeroDivisionError)
29
+ end
30
+ end
31
+
32
+ describe Dentaku::ParseError do
33
+ it 'builds a message from reason and metadata' do
34
+ exception = described_class.for(:too_few_operands, operation: Dentaku::AST::Addition, expected: 2, actual: 1)
35
+ expect(exception.message).to eq('Dentaku::AST::Addition has too few operands (given 1, expected 2)')
36
+ end
37
+
38
+ it 'constructs with sparse metadata' do
39
+ expect(described_class.for(:node_invalid).message).to eq('Node is invalid')
40
+ end
41
+
42
+ it 'describes an :incompatible expectation as requiring compatible operands' do
43
+ exception = described_class.for(:node_invalid, operation: Dentaku::AST::Addition, expected: :incompatible, actual: :string)
44
+ expect(exception.message).to eq('Dentaku::AST::Addition requires compatible operands, but got string')
45
+ end
46
+
47
+ it 'allows call sites to override the default message' do
48
+ expect {
49
+ raise described_class.for(:node_invalid), 'Case missing switch variable'
50
+ }.to raise_error(described_class, 'Case missing switch variable')
51
+ end
52
+ end
53
+
54
+ describe Dentaku::TokenizerError do
55
+ it 'builds a message from reason and metadata' do
56
+ exception = described_class.for(:parse_error, at: '$5')
57
+ expect(exception.message).to eq("parse error at: '$5'")
58
+ end
59
+ end
60
+
61
+ describe Dentaku::ArgumentError do
62
+ it 'builds an incompatible_type message from a function context' do
63
+ exception = described_class.for(:incompatible_type, function_name: 'AND', expected: :logical, actual: 5)
64
+ expect(exception.message).to eq('AND() requires logical arguments, but got Integer')
65
+ end
66
+
67
+ it 'builds an incompatible_type message from expected and actual' do
68
+ exception = described_class.for(:incompatible_type, expected: Integer, actual: 2.5)
69
+ expect(exception.message).to eq('Float is not compatible with Integer')
70
+ end
71
+
72
+ it 'builds an invalid_operator message' do
73
+ exception = described_class.for(:invalid_operator, operation: Dentaku::AST::Addition, operator: :+)
74
+ expect(exception.message).to eq('Dentaku::AST::Addition requires operands that respond to +')
75
+ end
76
+
77
+ it 'describes an at-least arity as a range' do
78
+ exception = described_class.for(:too_few_arguments, function_name: 'SUM', expected: 1.., actual: 0)
79
+ expect(exception.message).to eq('SUM() has too few arguments (given 0, expected at least 1)')
80
+ end
81
+
82
+ it 'describes an exact arity as a count' do
83
+ exception = described_class.for(:wrong_number_of_arguments, function_name: 'INTERCEPT', expected: 2, actual: 4)
84
+ expect(exception.message).to eq('INTERCEPT() has the wrong number of arguments (given 4, expected 2)')
85
+ end
86
+
87
+ it 'constructs with sparse metadata' do
88
+ expect(described_class.for(:invalid_value).message).to eq('Invalid value')
89
+ end
90
+
91
+ it 'allows call sites to override the default message' do
92
+ expect {
93
+ raise described_class.for(:invalid_value), 'INTERCEPT() requires arrays of equal length'
94
+ }.to raise_error(described_class, 'INTERCEPT() requires arrays of equal length')
95
+ end
96
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'dentaku.gemspec' do
4
+ # tsort no longer ships as a default gem in Ruby 4.1 (PR #334)
5
+ it 'declares tsort as a runtime dependency' do
6
+ gemspec = Gem::Specification.load(File.expand_path('../dentaku.gemspec', __dir__))
7
+ runtime_deps = gemspec.dependencies.select { |d| d.type == :runtime }.map(&:name)
8
+ expect(runtime_deps).to include('tsort')
9
+ end
10
+ end
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+ require 'dentaku'
3
+ require 'dentaku/calculator'
4
+
5
+ describe Dentaku::Calculator do
6
+ describe '#identifiers' do
7
+ let(:calculator) { described_class.new }
8
+
9
+ it 'does not prune literal guards' do
10
+ expect(calculator.identifiers('IF(1 > 0, a, b)')).to eq(%w(a b))
11
+ end
12
+
13
+ it 'includes the guard identifiers themselves' do
14
+ expect(calculator.identifiers('IF(x > 5, y, z)')).to eq(%w(x y z))
15
+ end
16
+
17
+ it 'never executes functions, pure or volatile' do
18
+ count = { pure: 0, volatile: 0 }
19
+ calculator.add_function(:purefn, :numeric, ->(x) { count[:pure] += 1; x })
20
+ calculator.add_function(:volfn, :numeric, ->(x) { count[:volatile] += 1; x }, volatile: true)
21
+
22
+ expect(calculator.identifiers('IF(purefn(1) > 0, a, IF(volfn(2) > 0, b, c))')).to eq(%w(a b c))
23
+ expect(count).to eq(pure: 0, volatile: 0)
24
+ end
25
+
26
+ it 'includes switch and every branch of CASE' do
27
+ formula = 'CASE fruit WHEN w1 THEN t1 WHEN 2 THEN t2 ELSE e1 END'
28
+ expect(calculator.identifiers(formula)).to eq(%w(fruit w1 t1 t2 e1))
29
+ end
30
+
31
+ it 'includes both operands of AND/OR even when one is decisive' do
32
+ expect(calculator.identifiers('1 > 2 AND x')).to eq(['x'])
33
+ expect(calculator.identifiers('a AND b OR c')).to eq(%w(a b c))
34
+ end
35
+
36
+ it 'excludes enum-bound variables' do
37
+ expect(calculator.identifiers('MAP(users, u, u.age + bonus)')).to eq(%w(users bonus))
38
+ end
39
+
40
+ it 'ignores stored memory' do
41
+ calculator.store(a: 1)
42
+ expect(calculator.identifiers('a + b')).to eq(%w(a b))
43
+ end
44
+
45
+ it 'accepts an AST node' do
46
+ node = calculator.ast('x + y')
47
+ expect(calculator.identifiers(node)).to eq(%w(x y))
48
+ end
49
+
50
+ it 'accepts an array of expressions and deduplicates' do
51
+ expect(calculator.identifiers(['a + b', 'b + c'])).to eq(%w(a b c))
52
+ end
53
+
54
+ it 'deduplicates repeated identifiers' do
55
+ expect(calculator.identifiers('a + a * a')).to eq(['a'])
56
+ end
57
+
58
+ it 'lists all inputs of a formula using external-state functions' do
59
+ user_attr = ->(name, default = 0) { default }
60
+ calculator.add_function(:user_attr, :numeric, user_attr)
61
+
62
+ identifiers = calculator.identifiers("IF(user_attr('level') > 50, high_level_value, low_level_value)")
63
+ expect(identifiers).to eq(%w(high_level_value low_level_value))
64
+ end
65
+ end
66
+ end
data/spec/parser_spec.rb CHANGED
@@ -84,6 +84,17 @@ describe Dentaku::Parser do
84
84
  expect(node.value("x" => 1, "y" => "A", "Y" => "B")).to eq(3)
85
85
  end
86
86
 
87
+ it 'evaluates a case statement with an unparenthesized operation as switch' do
88
+ node = parse('CASE a % 5 WHEN 0 THEN a ELSE b END')
89
+ expect(node.value("a" => 10, "b" => 1)).to eq(10)
90
+ expect(node.value("a" => 7, "b" => 1)).to eq(1)
91
+ end
92
+
93
+ it 'evaluates a case statement with a comparator as switch' do
94
+ node = parse('CASE a > 5 WHEN true THEN a ELSE b END')
95
+ expect(node.value("a" => 10, "b" => 1)).to eq(10)
96
+ end
97
+
87
98
  it 'evaluates arrays' do
88
99
  node = parse('{}')
89
100
  expect(node.value).to eq([])
@@ -111,6 +122,21 @@ describe Dentaku::Parser do
111
122
  }.to raise_error(Dentaku::ParseError)
112
123
  end
113
124
 
125
+ it 'preserves the node-level message for incompatible operands' do
126
+ expect {
127
+ parse('"hello" + 1')
128
+ }.to raise_error(
129
+ Dentaku::ParseError,
130
+ /requires operands that are numeric or compatible types, not string/
131
+ )
132
+ end
133
+
134
+ it 'preserves the node-level message for non-logical combinator operands' do
135
+ expect {
136
+ parse('1 AND 2')
137
+ }.to raise_error(Dentaku::ParseError, /requires logical operands/)
138
+ end
139
+
114
140
  it 'raises a parse error for too many operands' do
115
141
  expect {
116
142
  parse("IF(1, 0, IF(1, 2, 3, 4))")
@@ -167,6 +193,15 @@ describe Dentaku::Parser do
167
193
  parse("undefined()")
168
194
  }.to raise_error(Dentaku::ParseError)
169
195
  end
196
+
197
+ it 'raises a parse error for an unbalanced closing parenthesis' do
198
+ # the tokenizer catches unbalanced parentheses in a string expression,
199
+ # so exercise the parser directly with a hand-built token list
200
+ rparen = Dentaku::Token.new(:grouping, :close)
201
+ expect {
202
+ described_class.new([rparen]).parse
203
+ }.to raise_error(Dentaku::ParseError)
204
+ end
170
205
  end
171
206
 
172
207
  it "evaluates explicit 'NULL' as nil" do
@@ -49,6 +49,11 @@ describe Dentaku::PrintVisitor do
49
49
  expect(repr).to eq('CASE a % 5 WHEN 0 THEN a ELSE b END')
50
50
  end
51
51
 
52
+ it 'prints a case statement that can be re-parsed' do
53
+ repr = roundtrip('case (a % 5) when 0 then a else b end')
54
+ expect(roundtrip(repr)).to eq(repr)
55
+ end
56
+
52
57
  it 'handles a bitwise operators' do
53
58
  repr = roundtrip('0xCAFE & 0xDECAF | 0xBEEF')
54
59
  expect(repr).to eq('0xCAFE & 0xDECAF | 0xBEEF')