dentaku 2.0.11 → 3.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.travis.yml +0 -1
  4. data/CHANGELOG.md +19 -0
  5. data/README.md +3 -2
  6. data/dentaku.gemspec +1 -0
  7. data/lib/dentaku/ast.rb +4 -0
  8. data/lib/dentaku/ast/access.rb +27 -0
  9. data/lib/dentaku/ast/arithmetic.rb +49 -7
  10. data/lib/dentaku/ast/case.rb +17 -3
  11. data/lib/dentaku/ast/combinators.rb +8 -2
  12. data/lib/dentaku/ast/function.rb +16 -0
  13. data/lib/dentaku/ast/function_registry.rb +15 -2
  14. data/lib/dentaku/ast/functions/and.rb +25 -0
  15. data/lib/dentaku/ast/functions/max.rb +1 -1
  16. data/lib/dentaku/ast/functions/min.rb +1 -1
  17. data/lib/dentaku/ast/functions/or.rb +25 -0
  18. data/lib/dentaku/ast/functions/round.rb +2 -2
  19. data/lib/dentaku/ast/functions/rounddown.rb +3 -2
  20. data/lib/dentaku/ast/functions/roundup.rb +3 -2
  21. data/lib/dentaku/ast/functions/ruby_math.rb +3 -3
  22. data/lib/dentaku/ast/functions/switch.rb +8 -0
  23. data/lib/dentaku/ast/identifier.rb +3 -2
  24. data/lib/dentaku/ast/negation.rb +5 -1
  25. data/lib/dentaku/ast/node.rb +3 -0
  26. data/lib/dentaku/bulk_expression_solver.rb +1 -2
  27. data/lib/dentaku/calculator.rb +7 -6
  28. data/lib/dentaku/exceptions.rb +75 -1
  29. data/lib/dentaku/parser.rb +73 -12
  30. data/lib/dentaku/token.rb +4 -0
  31. data/lib/dentaku/token_scanner.rb +20 -3
  32. data/lib/dentaku/tokenizer.rb +31 -4
  33. data/lib/dentaku/version.rb +1 -1
  34. data/spec/ast/addition_spec.rb +6 -6
  35. data/spec/ast/and_function_spec.rb +35 -0
  36. data/spec/ast/and_spec.rb +1 -1
  37. data/spec/ast/arithmetic_spec.rb +56 -0
  38. data/spec/ast/division_spec.rb +1 -1
  39. data/spec/ast/function_spec.rb +43 -6
  40. data/spec/ast/max_spec.rb +15 -0
  41. data/spec/ast/min_spec.rb +15 -0
  42. data/spec/ast/or_spec.rb +35 -0
  43. data/spec/ast/round_spec.rb +25 -0
  44. data/spec/ast/rounddown_spec.rb +25 -0
  45. data/spec/ast/roundup_spec.rb +25 -0
  46. data/spec/ast/switch_spec.rb +30 -0
  47. data/spec/calculator_spec.rb +26 -4
  48. data/spec/exceptions_spec.rb +1 -1
  49. data/spec/parser_spec.rb +22 -3
  50. data/spec/spec_helper.rb +12 -2
  51. data/spec/token_scanner_spec.rb +0 -4
  52. data/spec/tokenizer_spec.rb +40 -2
  53. metadata +39 -3
@@ -1,7 +1,8 @@
1
1
  require_relative '../function'
2
2
 
3
- Dentaku::AST::Function.register(:roundup, :numeric, ->(numeric, precision=0) {
3
+ Dentaku::AST::Function.register(:roundup, :numeric, lambda { |numeric, precision = 0|
4
+ precision = precision.to_i
4
5
  tens = 10.0**precision
5
- result = (numeric * tens).ceil / tens
6
+ result = (Dentaku::AST::Function.numeric(numeric) * tens).ceil / tens
6
7
  precision <= 0 ? result.to_i : result
7
8
  })
@@ -1,8 +1,8 @@
1
1
  # import all functions from Ruby's Math module
2
- require_relative "../function"
2
+ require_relative '../function'
3
3
 
4
4
  Math.methods(false).each do |method|
5
- Dentaku::AST::Function.register(method, :numeric, ->(*args) {
6
- Math.send(method, *args)
5
+ Dentaku::AST::Function.register(method, :numeric, lambda { |*args|
6
+ Math.send(method, *args.map { |arg| Dentaku::AST::Function.numeric(arg) })
7
7
  })
8
8
  end
@@ -0,0 +1,8 @@
1
+ require_relative '../function'
2
+
3
+ Dentaku::AST::Function.register(:switch, :logical, lambda { |*args|
4
+ value = args.shift
5
+ default = args.pop if args.size.odd?
6
+ match = args.find_index.each_with_index { |arg, index| index.even? && arg == value }
7
+ match ? args[match + 1] : default
8
+ })
@@ -11,7 +11,8 @@ module Dentaku
11
11
 
12
12
  def value(context={})
13
13
  v = context.fetch(identifier) do
14
- raise UnboundVariableError.new([identifier])
14
+ raise UnboundVariableError.new([identifier]),
15
+ "no value provided for variables: #{identifier}"
15
16
  end
16
17
 
17
18
  case v
@@ -23,7 +24,7 @@ module Dentaku
23
24
  end
24
25
 
25
26
  def dependencies(context={})
26
- context.has_key?(identifier) ? dependencies_of(context[identifier]) : [identifier]
27
+ context.key?(identifier) ? dependencies_of(context[identifier]) : [identifier]
27
28
  end
28
29
 
29
30
  private
@@ -3,7 +3,11 @@ module Dentaku
3
3
  class Negation < Arithmetic
4
4
  def initialize(node)
5
5
  @node = node
6
- fail ParseError, "Negation requires numeric operand" unless valid_node?(node)
6
+
7
+ unless valid_node?(node)
8
+ raise NodeError.new(:numeric, left.type, :left),
9
+ "#{self.class} requires numeric operands"
10
+ end
7
11
  end
8
12
 
9
13
  def operator
@@ -9,6 +9,9 @@ module Dentaku
9
9
  nil
10
10
  end
11
11
 
12
+ def self.peek(*)
13
+ end
14
+
12
15
  def dependencies(context={})
13
16
  []
14
17
  end
@@ -1,4 +1,3 @@
1
- require 'dentaku/calculator'
2
1
  require 'dentaku/dependency_resolver'
3
2
  require 'dentaku/exceptions'
4
3
  require 'dentaku/parser'
@@ -55,7 +54,7 @@ module Dentaku
55
54
  evaluate!(expressions[var_name], expressions.merge(r))
56
55
 
57
56
  r[var_name] = value
58
- rescue Dentaku::UnboundVariableError, ZeroDivisionError => ex
57
+ rescue UnboundVariableError, Dentaku::ZeroDivisionError => ex
59
58
  ex.recipient_variable = var_name
60
59
  r[var_name] = block.call(ex)
61
60
  end
@@ -21,10 +21,6 @@ module Dentaku
21
21
  Dentaku::AST::FunctionRegistry.default.register(name, type, body)
22
22
  end
23
23
 
24
- def add_functions(fns)
25
- fns.each { |(name, type, body)| add_function(name, type, body) }
26
- end
27
-
28
24
  def add_function(name, type, body)
29
25
  @function_registry.register(name, type, body)
30
26
  self
@@ -44,7 +40,7 @@ module Dentaku
44
40
 
45
41
  def evaluate(expression, data={})
46
42
  evaluate!(expression, data)
47
- rescue UnboundVariableError, ArgumentError
43
+ rescue UnboundVariableError, Dentaku::ArgumentError
48
44
  yield expression if block_given?
49
45
  end
50
46
 
@@ -52,6 +48,11 @@ module Dentaku
52
48
  store(data) do
53
49
  node = expression
54
50
  node = ast(node) unless node.is_a?(AST::Node)
51
+ unbound = node.dependencies - memory.keys
52
+ unless unbound.empty?
53
+ raise UnboundVariableError.new(unbound),
54
+ "no value provided for variables: #{unbound.join(', ')}"
55
+ end
55
56
  node.value(memory)
56
57
  end
57
58
  end
@@ -85,7 +86,7 @@ module Dentaku
85
86
  when Regexp
86
87
  @ast_cache.delete_if { |k,_| k =~ pattern }
87
88
  else
88
- fail Dentaku::ArgumentError
89
+ raise ::ArgumentError
89
90
  end
90
91
  end
91
92
 
@@ -6,17 +6,91 @@ module Dentaku
6
6
 
7
7
  def initialize(unbound_variables)
8
8
  @unbound_variables = unbound_variables
9
- super("no value provided for variables: #{ unbound_variables.join(', ') }")
9
+ end
10
+ end
11
+
12
+ class NodeError < StandardError
13
+ attr_reader :child, :expect, :actual
14
+
15
+ def initialize(expect, actual, child)
16
+ @expect = Array(expect)
17
+ @actual = actual
18
+ @child = child
10
19
  end
11
20
  end
12
21
 
13
22
  class ParseError < StandardError
23
+ attr_reader :reason, :meta
24
+
25
+ def initialize(reason, **meta)
26
+ @reason = reason
27
+ @meta = meta
28
+ end
29
+
30
+ private_class_method :new
31
+
32
+ VALID_REASONS = %i[
33
+ node_invalid too_few_operands undefined_function
34
+ unprocessed_token unknown_case_token unbalanced_bracket
35
+ unbalanced_parenthesis unknown_grouping_token not_implemented_token_category
36
+ invalid_statement
37
+ ].freeze
38
+
39
+ def self.for(reason, **meta)
40
+ unless VALID_REASONS.include?(reason)
41
+ raise ::ArgumentError, "Unhandled #{reason}"
42
+ end
43
+
44
+ new reason, meta
45
+ end
14
46
  end
15
47
 
16
48
  class TokenizerError < StandardError
49
+ attr_reader :reason, :meta
50
+
51
+ def initialize(reason, **meta)
52
+ @reason = reason
53
+ @meta = meta
54
+ end
55
+
56
+ private_class_method :new
57
+
58
+ VALID_REASONS = %i[
59
+ parse_error too_many_opening_parentheses too_many_closing_parentheses
60
+ unexpected_zero_width_match
61
+ ].freeze
62
+
63
+ def self.for(reason, **meta)
64
+ unless VALID_REASONS.include?(reason)
65
+ raise ::ArgumentError, "Unhandled #{reason}"
66
+ end
67
+
68
+ new reason, meta
69
+ end
17
70
  end
18
71
 
19
72
  class ArgumentError < ::ArgumentError
73
+ attr_reader :reason, :meta
74
+
75
+ def initialize(reason, **meta)
76
+ @reason = reason
77
+ @meta = meta
78
+ end
79
+
80
+ private_class_method :new
81
+
82
+ VALID_REASONS = %i[
83
+ invalid_operator invalid_value too_few_arguments
84
+ too_much_arguments incompatible_type
85
+ ].freeze
86
+
87
+ def self.for(reason, **meta)
88
+ unless VALID_REASONS.include?(reason)
89
+ raise ::ArgumentError, "Unhandled #{reason}"
90
+ end
91
+
92
+ new reason, meta
93
+ end
20
94
  end
21
95
 
22
96
  class ZeroDivisionError < ::ZeroDivisionError
@@ -12,13 +12,19 @@ module Dentaku
12
12
  @function_registry = options.fetch(:function_registry, nil)
13
13
  end
14
14
 
15
- def get_args(count)
16
- Array.new(count) { output.pop }.reverse
17
- end
18
-
19
15
  def consume(count=2)
20
16
  operator = operations.pop
21
- output.push operator.new(*get_args(operator.arity || count))
17
+ operator.peek(output)
18
+
19
+ args_size = operator.arity || count
20
+ if args_size > output.length
21
+ fail! :too_few_operands, operator: operator, expect: args_size, actual: output.length
22
+ end
23
+ args = Array.new(args_size) { output.pop }.reverse
24
+
25
+ output.push operator.new(*args)
26
+ rescue NodeError => e
27
+ fail! :node_invalid, operator: operator, child: e.child, expect: e.expect, actual: e.actual
22
28
  end
23
29
 
24
30
  def parse
@@ -62,8 +68,13 @@ module Dentaku
62
68
  output.push AST::Nil.new
63
69
 
64
70
  when :function
71
+ func = function(token)
72
+ if func.nil?
73
+ fail! :undefined_function, function_name: token.value
74
+ end
75
+
65
76
  arities.push 0
66
- operations.push function(token)
77
+ operations.push func
67
78
 
68
79
  when :case
69
80
  case token.value
@@ -118,7 +129,7 @@ module Dentaku
118
129
  end
119
130
 
120
131
  unless operations.count == 1 && operations.last == AST::Case
121
- fail ParseError, "Unprocessed token #{ token.value }"
132
+ fail! :unprocessed_token, token_name: token.value
122
133
  end
123
134
  consume(arities.pop.succ)
124
135
  when :when
@@ -155,7 +166,22 @@ module Dentaku
155
166
 
156
167
  operations.push(AST::CaseElse)
157
168
  else
158
- fail ParseError, "Unknown case token #{ token.value }"
169
+ fail! :unknown_case_token, token_name: token.value
170
+ end
171
+
172
+ when :access
173
+ case token.value
174
+ when :lbracket
175
+ operations.push AST::Access
176
+ when :rbracket
177
+ while operations.any? && operations.last != AST::Access
178
+ consume
179
+ end
180
+
181
+ unless operations.last == AST::Access
182
+ fail! :unbalanced_bracket, token
183
+ end
184
+ consume
159
185
  end
160
186
 
161
187
  when :grouping
@@ -163,6 +189,7 @@ module Dentaku
163
189
  when :open
164
190
  if input.first && input.first.value == :close
165
191
  input.shift
192
+ arities.pop
166
193
  consume(0)
167
194
  else
168
195
  operations.push AST::Grouping
@@ -174,7 +201,9 @@ module Dentaku
174
201
  end
175
202
 
176
203
  lparen = operations.pop
177
- fail ParseError, "Unbalanced parenthesis" unless lparen == AST::Grouping
204
+ unless lparen == AST::Grouping
205
+ fail! :unbalanced_parenthesis, token
206
+ end
178
207
 
179
208
  if operations.last && operations.last < AST::Function
180
209
  consume(arities.pop.succ)
@@ -187,11 +216,11 @@ module Dentaku
187
216
  end
188
217
 
189
218
  else
190
- fail ParseError, "Unknown grouping token #{ token.value }"
219
+ fail! :unknown_grouping_token, token_name: token.value
191
220
  end
192
221
 
193
222
  else
194
- fail ParseError, "Not implemented for tokens of category #{ token.category }"
223
+ fail! :not_implemented_token_category, token_category: token.category
195
224
  end
196
225
  end
197
226
 
@@ -200,7 +229,7 @@ module Dentaku
200
229
  end
201
230
 
202
231
  unless output.count == 1
203
- fail ParseError, "Invalid statement"
232
+ fail! :invalid_statement
204
233
  end
205
234
 
206
235
  output.first
@@ -237,5 +266,37 @@ module Dentaku
237
266
  def function_registry
238
267
  @function_registry ||= Dentaku::AST::FunctionRegistry.new
239
268
  end
269
+
270
+ private
271
+
272
+ def fail!(reason, **meta)
273
+ message =
274
+ case reason
275
+ when :node_invalid
276
+ "#{meta.fetch(:operator)} requires #{meta.fetch(:expect).join(', ')} operands, but got #{meta.fetch(:actual)}"
277
+ when :too_few_operands
278
+ "#{meta.fetch(:operator)} has too few operands"
279
+ when :undefined_function
280
+ "Undefined function #{meta.fetch(:function_name)}"
281
+ when :unprocessed_token
282
+ "Unprocessed token #{meta.fetch(:token_name)}"
283
+ when :unknown_case_token
284
+ "Unknown case token #{meta.fetch(:token_name)}"
285
+ when :unbalanced_bracket
286
+ "Unbalanced bracket"
287
+ when :unbalanced_parenthesis
288
+ "Unbalanced parenthesis"
289
+ when :unknown_grouping_token
290
+ "Unknown grouping token #{meta.fetch(:token_name)}"
291
+ when :not_implemented_token_category
292
+ "Not implemented for tokens of category #{meta.fetch(:token_category)}"
293
+ when :invalid_statement
294
+ "Invalid statement"
295
+ else
296
+ raise ::ArgumentError, "Unhandled #{reason}"
297
+ end
298
+
299
+ raise ParseError.for(reason, meta), message
300
+ end
240
301
  end
241
302
  end
@@ -16,6 +16,10 @@ module Dentaku
16
16
  raw_value.to_s.length
17
17
  end
18
18
 
19
+ def empty?
20
+ length.zero?
21
+ end
22
+
19
23
  def grouping?
20
24
  is?(:grouping)
21
25
  end
@@ -31,14 +31,16 @@ module Dentaku
31
31
  :whitespace,
32
32
  :datetime, # before numeric so it can pick up timestamps
33
33
  :numeric,
34
+ :hexadecimal,
34
35
  :double_quoted_string,
35
36
  :single_quoted_string,
36
37
  :negate,
38
+ :combinator,
37
39
  :operator,
38
40
  :grouping,
41
+ :access,
39
42
  :case_statement,
40
43
  :comparator,
41
- :combinator,
42
44
  :boolean,
43
45
  :function,
44
46
  :identifier
@@ -81,7 +83,13 @@ module Dentaku
81
83
  end
82
84
 
83
85
  def numeric
84
- new(:numeric, '(\d+(\.\d+)?|\.\d+)\b', lambda { |raw| raw =~ /\./ ? BigDecimal.new(raw) : raw.to_i })
86
+ new(:numeric, '((?:\d+(\.\d+)?|\.\d+)(?:(e|E)(\+|-)?\d+)?)\b', lambda { |raw|
87
+ raw =~ /\./ ? BigDecimal.new(raw) : raw.to_i
88
+ })
89
+ end
90
+
91
+ def hexadecimal
92
+ new(:numeric, '(0x[0-9a-f]+)\b', lambda { |raw| raw[2..-1].to_i(16) })
85
93
  end
86
94
 
87
95
  def double_quoted_string
@@ -113,6 +121,11 @@ module Dentaku
113
121
  new(:grouping, '\(|\)|,', lambda { |raw| names[raw] })
114
122
  end
115
123
 
124
+ def access
125
+ names = { lbracket: '[', rbracket: ']' }.invert
126
+ new(:access, '\[|\]', lambda { |raw| names[raw] })
127
+ end
128
+
116
129
  def case_statement
117
130
  names = { open: 'case', close: 'end', then: 'then', when: 'when', else: 'else' }.invert
118
131
  new(:case, '(case|end|then|when|else)\b', lambda { |raw| names[raw.downcase] })
@@ -125,7 +138,11 @@ module Dentaku
125
138
  end
126
139
 
127
140
  def combinator
128
- new(:combinator, '(and|or)\b', lambda { |raw| raw.strip.downcase.to_sym })
141
+ names = { and: '&&', or: '||' }.invert
142
+ new(:combinator, '(and|or|&&|\|\|)\s', lambda { |raw|
143
+ norm = raw.strip.downcase
144
+ names.fetch(norm) { norm.to_sym }
145
+ })
129
146
  end
130
147
 
131
148
  def boolean
@@ -13,13 +13,17 @@ module Dentaku
13
13
  input = strip_comments(string.to_s.dup)
14
14
 
15
15
  until input.empty?
16
- fail TokenizerError, "parse error at: '#{ input }'" unless TokenScanner.scanners.any? do |scanner|
16
+ scanned = TokenScanner.scanners.any? do |scanner|
17
17
  scanned, input = scan(input, scanner)
18
18
  scanned
19
19
  end
20
+
21
+ unless scanned
22
+ fail! :parse_error, at: input
23
+ end
20
24
  end
21
25
 
22
- fail TokenizerError, "too many opening parentheses" if @nesting > 0
26
+ fail! :too_many_opening_parentheses if @nesting > 0
23
27
 
24
28
  @tokens
25
29
  end
@@ -31,11 +35,14 @@ module Dentaku
31
35
  def scan(string, scanner)
32
36
  if tokens = scanner.scan(string, last_token)
33
37
  tokens.each do |token|
34
- fail TokenizerError, "unexpected zero-width match (:#{ token.category }) at '#{ string }'" if token.length == 0
38
+ if token.empty?
39
+ fail! :unexpected_zero_width_match,
40
+ token_category: token.category, at: string
41
+ end
35
42
 
36
43
  @nesting += 1 if LPAREN == token
37
44
  @nesting -= 1 if RPAREN == token
38
- fail TokenizerError, "too many closing parentheses" if @nesting < 0
45
+ fail! :too_many_closing_parentheses if @nesting < 0
39
46
 
40
47
  @tokens << token unless token.is?(:whitespace)
41
48
  end
@@ -50,5 +57,25 @@ module Dentaku
50
57
  def strip_comments(input)
51
58
  input.gsub(/\/\*[^*]*\*+(?:[^*\/][^*]*\*+)*\//, '')
52
59
  end
60
+
61
+ private
62
+
63
+ def fail!(reason, **meta)
64
+ message =
65
+ case reason
66
+ when :parse_error
67
+ "parse error at: '#{meta.fetch(:at)}'"
68
+ when :too_many_opening_parentheses
69
+ "too many opening parentheses"
70
+ when :too_many_closing_parentheses
71
+ "too many closing parentheses"
72
+ when :unexpected_zero_width_match
73
+ "unexpected zero-width match (:#{meta.fetch(:category)}) at '#{meta.fetch(:at)}'"
74
+ else
75
+ raise ::ArgumentError, "Unhandled #{reason}"
76
+ end
77
+
78
+ raise TokenizerError.for(reason, meta), message
79
+ end
53
80
  end
54
81
  end