dentaku 2.0.11 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
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