dentaku 3.5.4 → 3.5.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef9e8cb8d852db9a8e4c81c28815c6a7e7008c6e9bebcc8a11222768756fd7a8
4
- data.tar.gz: f7d1c005ef1ba8bcfc77fcac8b885378a0e3e36fdba8f97a2c602b6b0ba6f34c
3
+ metadata.gz: fc1bbfde891d6c98eda08b9176f9ed791744b2043176f07d3aac997fae34a3e9
4
+ data.tar.gz: b8658f3d74364672f1a4538928e40d7b4d7aff7489273b573dbd1970303a60b3
5
5
  SHA512:
6
- metadata.gz: 6d616fd597440c13e8432ac6f9d5480592cdcbb7e9aec30c1f2c21e94e0a4bcf3ffced31cf486dc7390bd8668bb5e377745a7eebf467edfab1cd5fa0ae4f70e9
7
- data.tar.gz: fca27bf921a54469f065f634a44d4a6481c6420ccbcaa39400ffe4a5cdd172ff6ae209a04aee96208a64979bbff814635719732b557df33f7d406f87123b1c3a
6
+ metadata.gz: bc53fba03a05ddf47875ffd08aa54266b3f6894a91d292f18236aba4db4c4c270bfeb4f039f84f10d4ac7ce2ca0f6a86580d65c1f5a0169b74bd7c2dfacf9363
7
+ data.tar.gz: 88a65a6e103b40fbf3ef0dfc89173ced19e3df7f568514f4a12b451f7d4a91c69b1e25af89b5f9f1c9cadf34b71c200fc4a14ea11ef49aeeb2f2d4be6e36da2e
@@ -0,0 +1,26 @@
1
+ name: rspec
2
+ on:
3
+ push:
4
+ pull_request:
5
+ jobs:
6
+ rspec:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ ruby:
11
+ - '2.5'
12
+ - '2.6'
13
+ - '2.7'
14
+ - '3.0'
15
+ - '3.1'
16
+ - '3.2'
17
+ - '3.3'
18
+ - '3.4'
19
+ fail-fast: false
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: "${{ matrix.ruby }}"
25
+ bundler-cache: true
26
+ - run: bundle exec rspec --format documentation
@@ -0,0 +1,14 @@
1
+ name: rubocop
2
+ on:
3
+ push:
4
+ pull_request:
5
+ jobs:
6
+ rubocop:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: ruby/setup-ruby@v1
11
+ with:
12
+ ruby-version: '3'
13
+ bundler-cache: true
14
+ - run: bundle exec rubocop
data/CHANGELOG.md CHANGED
@@ -1,13 +1,20 @@
1
1
  # Change Log
2
2
 
3
- ## [v3.5.4]
3
+ ## [v3.5.5] 2025-08-20
4
+ - fix percentages in print visitor
5
+ - repo cleanup
6
+ - fix modulo zero
7
+ - fix array arithmetic
8
+ - refactor parser
9
+
10
+ ## [v3.5.4] 2024-08-13
4
11
  - add support for default value for PLUCK function
5
12
  - improve error handling for MAP/ANY/ALL functions
6
13
  - fix modulo / percentage operator determination
7
14
  - fix string casing bug with bulk expressions
8
15
  - add explicit gem dependency for BigDecimal
9
16
 
10
- ## [v3.5.3]
17
+ ## [v3.5.3] 2024-07-04
11
18
  - add support for empty array literals
12
19
  - add support for quoted identifiers
13
20
  - add REDUCE function
@@ -16,7 +23,7 @@
16
23
  - improve custom class arithmetic
17
24
  - fix IF dependency
18
25
 
19
- ## [v3.5.2]
26
+ ## [v3.5.2] 2023-12-06
20
27
  - add ABS function
21
28
  - add array support for AST visitors
22
29
  - add support for function callbacks
@@ -29,14 +36,14 @@
29
36
  - fix handling of Math::DomainError
30
37
  - fix invalid cast
31
38
 
32
- ## [v3.5.1]
39
+ ## [v3.5.1] 2022-10-24
33
40
  - add bitwise shift left and shift right operators
34
41
  - improve numeric conversions
35
42
  - improve parse exceptions
36
43
  - improve bitwise exceptions
37
44
  - include variable name in bulk expression exceptions
38
45
 
39
- ## [v3.5.0]
46
+ ## [v3.5.0] 2022-03-17
40
47
  - fix bug with function argument count
41
48
  - add XOR operator
42
49
  - make function args publicly accessible
@@ -48,7 +55,7 @@
48
55
  - respect case sensitivity in nested case statments
49
56
  - add visitor pattern
50
57
 
51
- ## [v3.4.2]
58
+ ## [v3.4.2] 2021-07-14
52
59
  - add FILTER function
53
60
  - add concurrent-ruby dependency to make global calculator object thread safe
54
61
  - add Ruby 3 support
@@ -260,6 +267,7 @@
260
267
  ## [v0.1.0] 2012-01-20
261
268
  - initial release
262
269
 
270
+ [v3.5.5]: https://github.com/rubysolo/dentaku/compare/v3.5.4...v3.5.5
263
271
  [v3.5.4]: https://github.com/rubysolo/dentaku/compare/v3.5.3...v3.5.4
264
272
  [v3.5.3]: https://github.com/rubysolo/dentaku/compare/v3.5.2...v3.5.3
265
273
  [v3.5.2]: https://github.com/rubysolo/dentaku/compare/v3.5.1...v3.5.2
data/README.md CHANGED
@@ -3,9 +3,6 @@ Dentaku
3
3
 
4
4
  [![Join the chat at https://gitter.im/rubysolo/dentaku](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/rubysolo/dentaku?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
5
5
  [![Gem Version](https://badge.fury.io/rb/dentaku.png)](http://badge.fury.io/rb/dentaku)
6
- [![Build Status](https://travis-ci.org/rubysolo/dentaku.png?branch=master)](https://travis-ci.org/rubysolo/dentaku)
7
- [![Code Climate](https://codeclimate.com/github/rubysolo/dentaku.png)](https://codeclimate.com/github/rubysolo/dentaku)
8
- [![Coverage](https://codecov.io/gh/rubysolo/dentaku/branch/master/graph/badge.svg)](https://codecov.io/gh/rubysolo/dentaku)
9
6
 
10
7
 
11
8
  DESCRIPTION
@@ -147,7 +144,7 @@ Logic: `IF`, `AND`, `OR`, `XOR`, `NOT`, `SWITCH`
147
144
 
148
145
  Numeric: `MIN`, `MAX`, `SUM`, `AVG`, `COUNT`, `ROUND`, `ROUNDDOWN`, `ROUNDUP`, `ABS`, `INTERCEPT`
149
146
 
150
- Selections: `CASE` (syntax see [spec](https://github.com/rubysolo/dentaku/blob/master/spec/calculator_spec.rb#L593))
147
+ Selections: `CASE` (syntax see [spec](https://github.com/rubysolo/dentaku/blob/main/lib/dentaku/ast/case.rb))
151
148
 
152
149
  String: `LEFT`, `RIGHT`, `MID`, `LEN`, `FIND`, `SUBSTITUTE`, `CONCAT`, `CONTAINS`
153
150
 
data/dentaku.gemspec CHANGED
@@ -17,7 +17,6 @@ Gem::Specification.new do |s|
17
17
  s.add_dependency('bigdecimal')
18
18
  s.add_dependency('concurrent-ruby')
19
19
 
20
- s.add_development_dependency('codecov')
21
20
  s.add_development_dependency('pry')
22
21
  s.add_development_dependency('pry-byebug')
23
22
  s.add_development_dependency('pry-stack_explorer')
@@ -13,13 +13,13 @@ module Dentaku
13
13
  super
14
14
 
15
15
  unless valid_left?
16
- raise NodeError.new(:numeric, left.type, :left),
17
- "#{self.class} requires numeric operands"
16
+ raise NodeError.new(:incompatible, left.type, :left),
17
+ "#{self.class} requires operands that are numeric or compatible types, not #{left.type}"
18
18
  end
19
19
 
20
20
  unless valid_right?
21
- raise NodeError.new(:numeric, right.type, :right),
22
- "#{self.class} requires numeric operands"
21
+ raise NodeError.new(:incompatible, right.type, :right),
22
+ "#{self.class} requires operands that are numeric or compatible types, not #{right.type}"
23
23
  end
24
24
  end
25
25
 
@@ -69,12 +69,25 @@ module Dentaku
69
69
  def datetime?(val)
70
70
  # val is a Date, Time, or DateTime
71
71
  return true if val.respond_to?(:strftime)
72
+ return false unless val.is_a?(::String)
72
73
 
73
- val.to_s =~ Dentaku::TokenScanner::DATE_TIME_REGEXP
74
+ val =~ Dentaku::TokenScanner::DATE_TIME_REGEXP
74
75
  end
75
76
 
76
77
  def valid_node?(node)
77
- node && (node.type == :numeric || node.type == :integer || node.dependencies.any?)
78
+ return false unless node
79
+
80
+ # Allow nodes with dependencies (identifiers that will be resolved later)
81
+ return true if node.dependencies.any?
82
+
83
+ # Allow compatible types
84
+ return true if [:numeric, :integer, :array].include?(node.type)
85
+
86
+ # Allow nodes without a type (operations, groupings)
87
+ return true if node.type.nil?
88
+
89
+ # Reject incompatible types
90
+ false
78
91
  end
79
92
 
80
93
  def valid_left?
@@ -193,6 +206,13 @@ module Dentaku
193
206
  def operator
194
207
  :%
195
208
  end
209
+
210
+ def value(context = {})
211
+ r = decimal(cast(right.value(context)))
212
+ raise Dentaku::ZeroDivisionError if r.zero?
213
+
214
+ cast(cast(left.value(context)) % r)
215
+ end
196
216
  end
197
217
 
198
218
  class Percentage < Arithmetic
@@ -201,26 +221,30 @@ module Dentaku
201
221
  end
202
222
 
203
223
  def initialize(child)
204
- @right = child
224
+ @left = child
205
225
 
206
- unless valid_right?
207
- raise NodeError.new(:numeric, right.type, :right),
226
+ unless valid_left?
227
+ raise NodeError.new(:numeric, left.type, :left),
208
228
  "#{self.class} requires a numeric operand"
209
229
  end
210
230
  end
211
231
 
212
232
  def dependencies(context = {})
213
- @right.dependencies(context)
233
+ @left.dependencies(context)
214
234
  end
215
235
 
216
236
  def value(context = {})
217
- cast(right.value(context)) * 0.01
237
+ cast(left.value(context)) * 0.01
218
238
  end
219
239
 
220
240
  def operator
221
241
  :%
222
242
  end
223
243
 
244
+ def operator_spacing
245
+ ""
246
+ end
247
+
224
248
  def self.precedence
225
249
  30
226
250
  end
@@ -7,6 +7,18 @@ require 'dentaku/exceptions'
7
7
 
8
8
  module Dentaku
9
9
  module AST
10
+ # Examples of using in a formula:
11
+ #
12
+ # CASE x WHEN 1 THEN 2 WHEN 3 THEN 4 ELSE END
13
+ #
14
+ # CASE fruit
15
+ # WHEN 'apple'
16
+ # THEN 1 * quantity
17
+ # WHEN 'banana'
18
+ # THEN 2 * quantity
19
+ # ELSE
20
+ # 3 * quantity
21
+ # END
10
22
  class Case < Node
11
23
  attr_reader :switch, :conditions, :else
12
24
 
@@ -20,7 +20,7 @@ module Dentaku
20
20
  r = validate_value(cast(right.value(context)))
21
21
 
22
22
  l.public_send(operator, r)
23
- rescue ::ArgumentError => e
23
+ rescue ::ArgumentError, ::TypeError => e
24
24
  raise Dentaku::ArgumentError.for(:incompatible_type, value: r, for: l.class), e.message
25
25
  end
26
26
 
@@ -33,7 +33,12 @@ module Dentaku
33
33
  def display_operator
34
34
  operator.to_s
35
35
  end
36
+
36
37
  alias_method :to_s, :display_operator
38
+
39
+ def operator_spacing
40
+ " "
41
+ end
37
42
  end
38
43
  end
39
44
  end
data/lib/dentaku/ast.rb CHANGED
@@ -39,4 +39,4 @@ require_relative './ast/functions/ruby_math'
39
39
  require_relative './ast/functions/string_functions'
40
40
  require_relative './ast/functions/sum'
41
41
  require_relative './ast/functions/switch'
42
- require_relative './ast/functions/xor'
42
+ require_relative './ast/functions/xor'
@@ -37,6 +37,7 @@ module Dentaku
37
37
  @arities = options.fetch(:arities, [])
38
38
  @function_registry = options.fetch(:function_registry, nil)
39
39
  @case_sensitive = options.fetch(:case_sensitive, false)
40
+ @skip_indices = []
40
41
  end
41
42
 
42
43
  def consume(count = 2)
@@ -53,17 +54,18 @@ module Dentaku
53
54
  fail! :too_few_operands, operator: operator, expect: expect, actual: output_size
54
55
  end
55
56
 
56
- if output_size > max_size && operations.empty? || args_size > max_size
57
+ if (output_size > max_size && operations.empty?) || args_size > max_size
57
58
  expect = min_size == max_size ? min_size : min_size..max_size
58
59
  fail! :too_many_operands, operator: operator, expect: expect, actual: output_size
59
60
  end
60
61
 
62
+ args = []
61
63
  if operator == AST::Array && output.empty?
62
- output.push(operator.new())
64
+ # special case: empty array literal '{}'
65
+ output.push(operator.new)
63
66
  else
64
67
  fail! :invalid_statement if output_size < args_size
65
68
  args = Array.new(args_size) { output.pop }.reverse
66
-
67
69
  output.push operator.new(*args)
68
70
  end
69
71
 
@@ -79,233 +81,21 @@ module Dentaku
79
81
  def parse
80
82
  return AST::Nil.new if input.empty?
81
83
 
82
- while token = input.shift
83
- case token.category
84
- when :datetime
85
- output.push AST::DateTime.new(token)
86
-
87
- when :numeric
88
- output.push AST::Numeric.new(token)
89
-
90
- when :logical
91
- output.push AST::Logical.new(token)
92
-
93
- when :string
94
- output.push AST::String.new(token)
95
-
96
- when :identifier
97
- output.push AST::Identifier.new(token, case_sensitive: case_sensitive)
98
-
99
- when :operator, :comparator, :combinator
100
- op_class = operation(token)
101
- op_class = op_class.resolve_class(input.first)
102
-
103
- if op_class.right_associative?
104
- while operations.last && operations.last < AST::Operation && op_class.precedence < operations.last.precedence
105
- consume
106
- end
107
-
108
- operations.push op_class
109
- else
110
- while operations.last && operations.last < AST::Operation && op_class.precedence <= operations.last.precedence
111
- consume
112
- end
113
-
114
- operations.push op_class
115
- end
116
-
117
- when :null
118
- output.push AST::Nil.new
119
-
120
- when :function
121
- func = function(token)
122
- if func.nil?
123
- fail! :undefined_function, function_name: token.value
124
- end
125
-
126
- arities.push 0
127
- operations.push func
128
-
129
- when :case
130
- case_index = operations.index { |o| o == AST::Case } || -1
131
- token_index = case_index + 1
132
-
133
- case token.value
134
- when :open
135
- # special handling for case nesting: strip out inner case
136
- # statements and parse their AST segments recursively
137
- if operations.include?(AST::Case)
138
- open_cases = 0
139
- case_end_index = nil
140
-
141
- input.each_with_index do |input_token, index|
142
- if input_token.category == :case
143
- if input_token.value == :open
144
- open_cases += 1
145
- end
146
-
147
- if input_token.value == :close
148
- if open_cases > 0
149
- open_cases -= 1
150
- else
151
- case_end_index = index
152
- break
153
- end
154
- end
155
- end
156
- end
157
- inner_case_inputs = input.slice!(0..case_end_index)
158
- subparser = Parser.new(
159
- inner_case_inputs,
160
- operations: [AST::Case],
161
- arities: [0],
162
- function_registry: @function_registry,
163
- case_sensitive: case_sensitive
164
- )
165
- subparser.parse
166
- output.concat(subparser.output)
167
- else
168
- operations.push AST::Case
169
- arities.push(0)
170
- end
171
- when :close
172
- if operations[token_index] == AST::CaseThen
173
- while operations.last != AST::Case
174
- consume
175
- end
176
-
177
- operations.push(AST::CaseConditional)
178
- consume(2)
179
- arities[-1] += 1
180
- elsif operations[token_index] == AST::CaseElse
181
- while operations.last != AST::Case
182
- consume
183
- end
184
-
185
- arities[-1] += 1
186
- end
187
-
188
- unless operations.count >= 1 && operations.last == AST::Case
189
- fail! :unprocessed_token, token_name: token.value
190
- end
191
- consume(arities.pop.succ)
192
- when :when
193
- if operations[token_index] == AST::CaseThen
194
- while ![AST::CaseWhen, AST::Case].include?(operations.last)
195
- consume
196
- end
197
- operations.push(AST::CaseConditional)
198
- consume(2)
199
- arities[-1] += 1
200
- elsif operations.last == AST::Case
201
- operations.push(AST::CaseSwitchVariable)
202
- consume
203
- end
204
-
205
- operations.push(AST::CaseWhen)
206
- when :then
207
- if operations[token_index] == AST::CaseWhen
208
- while ![AST::CaseThen, AST::Case].include?(operations.last)
209
- consume
210
- end
211
- end
212
- operations.push(AST::CaseThen)
213
- when :else
214
- if operations[token_index] == AST::CaseThen
215
- while operations.last != AST::Case
216
- consume
217
- end
218
-
219
- operations.push(AST::CaseConditional)
220
- consume(2)
221
- arities[-1] += 1
222
- end
223
-
224
- operations.push(AST::CaseElse)
225
- else
226
- fail! :unknown_case_token, token_name: token.value
227
- end
228
-
229
- when :access
230
- case token.value
231
- when :lbracket
232
- operations.push AST::Access
233
- when :rbracket
234
- while operations.any? && operations.last != AST::Access
235
- consume
236
- end
237
-
238
- unless operations.last == AST::Access
239
- fail! :unbalanced_bracket, token: token
240
- end
241
- consume
242
- end
243
-
244
- when :array
245
- case token.value
246
- when :array_start
247
- operations.push AST::Array
248
- arities.push 0
249
- when :array_end
250
- while operations.any? && operations.last != AST::Array
251
- consume
252
- end
253
-
254
- unless operations.last == AST::Array
255
- fail! :unbalanced_bracket, token: token
256
- end
257
-
258
- consume(arities.pop.succ)
259
- end
260
-
261
- when :grouping
262
- case token.value
263
- when :open
264
- if input.first && input.first.value == :close
265
- input.shift
266
- arities.pop
267
- consume(0)
268
- else
269
- operations.push AST::Grouping
270
- end
271
-
272
- when :close
273
- while operations.any? && operations.last != AST::Grouping
274
- consume
275
- end
276
-
277
- lparen = operations.pop
278
- unless lparen == AST::Grouping
279
- fail! :unbalanced_parenthesis, token
280
- end
281
-
282
- if operations.last && operations.last < AST::Function
283
- consume(arities.pop.succ)
284
- end
285
-
286
- when :comma
287
- fail! :invalid_statement if arities.empty?
288
- arities[-1] += 1
289
- while operations.any? && operations.last != AST::Grouping && operations.last != AST::Array
290
- consume
291
- end
292
-
293
- else
294
- fail! :unknown_grouping_token, token_name: token.value
295
- end
296
-
297
- else
298
- fail! :not_implemented_token_category, token_category: token.category
84
+ i = 0
85
+ while i < input.length
86
+ if @skip_indices.include?(i)
87
+ i += 1
88
+ next
299
89
  end
90
+ token = input[i]
91
+ lookahead = input[i + 1]
92
+ process_token(token, lookahead, i, input)
93
+ i += 1
300
94
  end
301
95
 
302
- while operations.any?
303
- consume
304
- end
96
+ consume while operations.any?
305
97
 
306
- unless output.count == 1
307
- fail! :invalid_statement
308
- end
98
+ fail! :invalid_statement unless output.count == 1
309
99
 
310
100
  output.first
311
101
  end
@@ -324,6 +114,175 @@ module Dentaku
324
114
 
325
115
  private
326
116
 
117
+ def process_token(token, lookahead, index, tokens)
118
+ case token.category
119
+ when :datetime then output << AST::DateTime.new(token)
120
+ when :numeric then output << AST::Numeric.new(token)
121
+ when :logical then output << AST::Logical.new(token)
122
+ when :string then output << AST::String.new(token)
123
+ when :identifier then output << AST::Identifier.new(token, case_sensitive: case_sensitive)
124
+ when :operator, :comparator, :combinator
125
+ handle_operator(token, lookahead)
126
+ when :null
127
+ output << AST::Nil.new
128
+ when :function
129
+ handle_function(token)
130
+ when :case
131
+ handle_case(token)
132
+ when :access
133
+ handle_access(token)
134
+ when :array
135
+ handle_array(token)
136
+ when :grouping
137
+ handle_grouping(token, lookahead, tokens)
138
+ else
139
+ fail! :not_implemented_token_category, token_category: token.category
140
+ end
141
+ end
142
+
143
+ def handle_operator(token, lookahead)
144
+ op_class = operation(token).resolve_class(lookahead)
145
+ if op_class.right_associative?
146
+ consume while operations.last && operations.last < AST::Operation && op_class.precedence < operations.last.precedence
147
+ else
148
+ consume while operations.last && operations.last < AST::Operation && op_class.precedence <= operations.last.precedence
149
+ end
150
+ operations.push op_class
151
+ end
152
+
153
+ def handle_function(token)
154
+ func = function(token)
155
+ fail! :undefined_function, function_name: token.value if func.nil?
156
+ arities.push 0
157
+ operations.push func
158
+ end
159
+
160
+ def handle_case(token)
161
+ # We always operate on the innermost (most recent) CASE on the stack.
162
+ case_index = operations.rindex(AST::Case) || -1
163
+ token_index = case_index + 1
164
+
165
+ case token.value
166
+ when :open
167
+ # Start a new CASE context.
168
+ operations.push AST::Case
169
+ arities.push(0)
170
+
171
+ when :close
172
+ # Finalize any trailing THEN/ELSE expression still on the stack.
173
+ if operations[token_index] == AST::CaseThen
174
+ consume_until(AST::Case)
175
+ operations.push(AST::CaseConditional)
176
+ consume(2)
177
+ arities[-1] += 1
178
+ elsif operations[token_index] == AST::CaseElse
179
+ consume_until(AST::Case)
180
+ arities[-1] += 1
181
+ end
182
+ fail! :unprocessed_token, token_name: token.value unless operations.last == AST::Case
183
+ consume(arities.pop.succ)
184
+
185
+ when :when
186
+ if operations[token_index] == AST::CaseThen
187
+ # Close out previous WHEN/THEN pair.
188
+ consume_until([AST::CaseWhen, AST::Case])
189
+ operations.push(AST::CaseConditional)
190
+ consume(2)
191
+ arities[-1] += 1
192
+ elsif operations.last == AST::Case
193
+ # First WHEN: finalize switch variable expression.
194
+ operations.push(AST::CaseSwitchVariable)
195
+ consume
196
+ end
197
+ operations.push(AST::CaseWhen)
198
+
199
+ when :then
200
+ if operations[token_index] == AST::CaseWhen
201
+ consume_until([AST::CaseThen, AST::Case])
202
+ end
203
+ operations.push(AST::CaseThen)
204
+
205
+ when :else
206
+ if operations[token_index] == AST::CaseThen
207
+ consume_until(AST::Case)
208
+ operations.push(AST::CaseConditional)
209
+ consume(2)
210
+ arities[-1] += 1
211
+ end
212
+ operations.push(AST::CaseElse)
213
+
214
+ else
215
+ fail! :unknown_case_token, token_name: token.value
216
+ end
217
+ end
218
+
219
+ def consume_until(target)
220
+ matcher =
221
+ case target
222
+ when Array then ->(op) { target.include?(op) }
223
+ else ->(op) { op == target }
224
+ end
225
+
226
+ consume while operations.any? && !matcher.call(operations.last)
227
+ end
228
+
229
+ def handle_access(token)
230
+ case token.value
231
+ when :lbracket
232
+ operations.push AST::Access
233
+
234
+ when :rbracket
235
+ consume while operations.any? && operations.last != AST::Access
236
+ fail! :unbalanced_bracket, token: token unless operations.last == AST::Access
237
+ consume
238
+ end
239
+ end
240
+
241
+ def handle_array(token)
242
+ case token.value
243
+ when :array_start
244
+ operations.push AST::Array
245
+ arities.push 0
246
+
247
+ when :array_end
248
+ consume while operations.any? && operations.last != AST::Array
249
+ fail! :unbalanced_bracket, token: token unless operations.last == AST::Array
250
+ consume(arities.pop.succ)
251
+ end
252
+ end
253
+
254
+ def handle_grouping(token, lookahead, tokens)
255
+ case token.value
256
+ when :open
257
+ if lookahead && lookahead.value == :close
258
+ # empty grouping (e.g. function with zero arguments) — we trigger consume later
259
+ # skip to the end
260
+ lookahead_index = tokens.index(lookahead)
261
+ @skip_indices << lookahead_index if lookahead_index
262
+ arities.pop
263
+ consume(0)
264
+ else
265
+ operations.push AST::Grouping
266
+ end
267
+
268
+ when :close
269
+ consume while operations.any? && operations.last != AST::Grouping
270
+ lparen = operations.pop
271
+ fail! :unbalanced_parenthesis, token unless lparen == AST::Grouping
272
+ if operations.last && operations.last < AST::Function
273
+ consume(arities.pop.succ)
274
+ end
275
+
276
+ when :comma
277
+ fail! :invalid_statement if arities.empty?
278
+ arities[-1] += 1
279
+ consume while operations.any? && operations.last != AST::Grouping && operations.last != AST::Array
280
+
281
+ else
282
+ fail! :unknown_grouping_token, token_name: token.value
283
+ end
284
+ end
285
+
327
286
  def fail!(reason, **meta)
328
287
  message =
329
288
  case reason
@@ -7,13 +7,13 @@ module Dentaku
7
7
 
8
8
  def visit_operation(node)
9
9
  if node.left
10
- visit_operand(node.left, node.class.precedence, suffix: " ", dir: :left)
10
+ visit_operand(node.left, node.class.precedence, suffix: node.operator_spacing, dir: :left)
11
11
  end
12
12
 
13
13
  @output << node.display_operator
14
14
 
15
15
  if node.right
16
- visit_operand(node.right, node.class.precedence, prefix: " ", dir: :right)
16
+ visit_operand(node.right, node.class.precedence, prefix: node.operator_spacing, dir: :right)
17
17
  end
18
18
  end
19
19
 
@@ -1,3 +1,3 @@
1
1
  module Dentaku
2
- VERSION = "3.5.4"
2
+ VERSION = "3.5.6"
3
3
  end
@@ -83,4 +83,4 @@ module Dentaku
83
83
  end
84
84
  end
85
85
  end
86
- end
86
+ end
@@ -20,10 +20,10 @@ describe Dentaku::AST::Addition do
20
20
  expect(node.value).to eq(11)
21
21
  end
22
22
 
23
- it 'requires numeric operands' do
23
+ it 'requires operands that respond to +' do
24
24
  expect {
25
25
  described_class.new(five, t)
26
- }.to raise_error(Dentaku::NodeError, /requires numeric operands/)
26
+ }.to raise_error(Dentaku::NodeError, /requires operands/)
27
27
 
28
28
  expression = Dentaku::AST::Multiplication.new(five, five)
29
29
  group = Dentaku::AST::Grouping.new(expression)
@@ -35,7 +35,6 @@ describe Dentaku::AST::Addition do
35
35
 
36
36
  it 'allows operands that respond to addition' do
37
37
  # Sample struct that has a custom definition for addition
38
-
39
38
  Addable = Struct.new(:value) do
40
39
  def +(other)
41
40
  case other
@@ -51,12 +50,18 @@ describe Dentaku::AST::Addition do
51
50
  operand_six = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Addable.new(6))
52
51
 
53
52
  expect {
54
- described_class.new(operand_five, operand_six)
53
+ described_class.new(operand_five, operand_six).value
55
54
  }.not_to raise_error
56
55
 
57
56
  expect {
58
- described_class.new(operand_five, six)
57
+ described_class.new(operand_five, six).value
59
58
  }.not_to raise_error
59
+ end
60
+
61
+ it 'does not try to parse nested string as date' do
62
+ a = ['2017-01-01', '2017-01-02']
63
+ b = ['2017-01-01']
60
64
 
65
+ expect(Dentaku('a + b', a: a, b: b)).to eq(['2017-01-01', '2017-01-02', '2017-01-01'])
61
66
  end
62
67
  end
@@ -111,6 +111,13 @@ describe Dentaku::AST::Arithmetic do
111
111
  end
112
112
  end
113
113
 
114
+ it 'does not try to parse nested string as date' do
115
+ a = ['2017-01-01', '2017-01-02']
116
+ b = ['2017-01-01']
117
+
118
+ expect(Dentaku('a - b', a: a, b: b)).to eq(['2017-01-02'])
119
+ end
120
+
114
121
  it 'raises ArgumentError if given individually valid but incompatible arguments' do
115
122
  expect { add(one, date) }.to raise_error(Dentaku::ArgumentError)
116
123
  expect { add(x, one, 'x' => [1]) }.to raise_error(Dentaku::ArgumentError)
@@ -20,10 +20,10 @@ describe Dentaku::AST::Division do
20
20
  expect(node.value.round(4)).to eq(0.8333)
21
21
  end
22
22
 
23
- it 'requires numeric operands' do
23
+ it 'requires operands that respond to /' do
24
24
  expect {
25
25
  described_class.new(five, t)
26
- }.to raise_error(Dentaku::NodeError, /requires numeric operands/)
26
+ }.to raise_error(Dentaku::NodeError, /requires operands/)
27
27
 
28
28
  expression = Dentaku::AST::Multiplication.new(five, five)
29
29
  group = Dentaku::AST::Grouping.new(expression)
@@ -44,17 +44,21 @@ describe Dentaku::AST::Division do
44
44
  value + other
45
45
  end
46
46
  end
47
+
48
+ def zero?
49
+ value.zero?
50
+ end
47
51
  end
48
52
 
49
53
  operand_five = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Divisible.new(5))
50
54
  operand_six = Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, Divisible.new(6))
51
55
 
52
56
  expect {
53
- described_class.new(operand_five, operand_six)
57
+ described_class.new(operand_five, operand_six).value
54
58
  }.not_to raise_error
55
59
 
56
60
  expect {
57
- described_class.new(operand_five, six)
61
+ described_class.new(operand_five, six).value
58
62
  }.not_to raise_error
59
63
  end
60
64
  end
@@ -33,13 +33,20 @@ RSpec.describe Dentaku::BulkExpressionSolver do
33
33
  }.to raise_error(Dentaku::UnboundVariableError)
34
34
  end
35
35
 
36
- it "lets you know if the result is a div/0 error" do
36
+ it "lets you know if the result is a div/0 error when dividing" do
37
37
  expressions = {more_apples: "1/0"}
38
38
  expect {
39
39
  described_class.new(expressions, calculator).solve!
40
40
  }.to raise_error(Dentaku::ZeroDivisionError)
41
41
  end
42
42
 
43
+ it "lets you know if the result is a div/0 error when taking modulo" do
44
+ expressions = {more_apples: "1%0"}
45
+ expect {
46
+ described_class.new(expressions, calculator).solve!
47
+ }.to raise_error(Dentaku::ZeroDivisionError)
48
+ end
49
+
43
50
  it "does not require keys to be parseable" do
44
51
  expressions = { "the value of x, incremented" => "x + 1" }
45
52
  solver = described_class.new(expressions, calculator.store("x" => 3))
@@ -130,6 +130,7 @@ describe Dentaku::Calculator do
130
130
  expect { calculator.evaluate!('"foo" & "bar"') }.to raise_error(Dentaku::ArgumentError)
131
131
  expect { calculator.evaluate!('1.0 & "bar"') }.to raise_error(Dentaku::ArgumentError)
132
132
  expect { calculator.evaluate!('1 & "bar"') }.to raise_error(Dentaku::ArgumentError)
133
+ expect { calculator.evaluate!('data < 1', data: { a: 5 }) }.to raise_error(Dentaku::ArgumentError)
133
134
  end
134
135
 
135
136
  it 'raises argument error if a function is called with incorrect arity' do
@@ -235,6 +236,12 @@ describe Dentaku::Calculator do
235
236
  expect(calculator.dependencies('MAP(vals, val, val + step)')).to eq(['vals', 'step'])
236
237
  expect(calculator.dependencies('ALL(people, person, person.age < adult)')).to eq(['people', 'adult'])
237
238
  end
239
+
240
+ it "raises an error when trying to find dependencies with invalid syntax" do
241
+ expect { calculator.dependencies('bob + / 3') }.to raise_error(Dentaku::ParseError)
242
+ expect { calculator.dependencies('123 * TRUE') }.to raise_error(Dentaku::ParseError)
243
+ expect { calculator.dependencies('4 + "asdf"') }.to raise_error(Dentaku::ParseError)
244
+ end
238
245
  end
239
246
 
240
247
  describe 'solve!' do
@@ -531,7 +538,7 @@ describe Dentaku::Calculator do
531
538
  expect(calculator.evaluate!('value + duration(1, month)', { value: value })).to eq(Time.local(2023, 8, 13, 10, 42, 11))
532
539
  expect(calculator.evaluate!('value - duration(1, day)', { value: value })).to eq(Time.local(2023, 7, 12, 10, 42, 11))
533
540
  expect(calculator.evaluate!('value - duration(1, year)', { value: value })).to eq(Time.local(2022, 7, 13, 10, 42, 11))
534
- expect(calculator.evaluate!('value2 - value', { value: value, value2: value2 })).to eq(12_182_399.0)
541
+ expect(calculator.evaluate!('value2 - value', { value: value, value2: value2 })).to eq(value2 - value)
535
542
  expect(calculator.evaluate!('value - 7200', { value: value })).to eq(Time.local(2023, 7, 13, 8, 42, 11))
536
543
  end
537
544
  end
@@ -166,7 +166,7 @@ describe Dentaku::Calculator do
166
166
  it 'adds multiple functions to default/global function registry' do
167
167
  described_class.add_functions([
168
168
  [:cube, :numeric, ->(x) { x**3 }],
169
- [:spongebob, :string, ->(x) { x.split("").each_with_index().map { |c,i| i.even? ? c.upcase : c.downcase }.join() }],
169
+ [:spongebob, :string, ->(x) { x.split("").each_with_index().map { |c, i| i.even? ? c.upcase : c.downcase }.join() }],
170
170
  ])
171
171
 
172
172
  expect(described_class.new.evaluate("1 + cube(3)")).to eq(28)
data/spec/parser_spec.rb CHANGED
@@ -27,6 +27,9 @@ describe Dentaku::Parser do
27
27
  it 'calculates bitwise OR' do
28
28
  node = parse('2|3')
29
29
  expect(node.value).to eq(3)
30
+
31
+ node = parse('(5 | 2) + 1')
32
+ expect(node.value).to eq(8)
30
33
  end
31
34
 
32
35
  it 'performs multiple operations in one stream' do
@@ -77,11 +80,17 @@ describe Dentaku::Parser do
77
80
  end
78
81
 
79
82
  it 'evaluates arrays' do
83
+ node = parse('{}')
84
+ expect(node.value).to eq([])
85
+
80
86
  node = parse('{1, 2, 3}')
81
87
  expect(node.value).to eq([1, 2, 3])
82
88
 
83
- node = parse('{}')
84
- expect(node.value).to eq([])
89
+ node = parse('{1, 2, 3} + {4,5,6}')
90
+ expect(node.value).to eq([1, 2, 3, 4, 5, 6])
91
+
92
+ node = parse('{1, 2, 3} - {2,3}')
93
+ expect(node.value).to eq([1])
85
94
  end
86
95
 
87
96
  context 'invalid expression' do
@@ -59,6 +59,11 @@ describe Dentaku::PrintVisitor do
59
59
  expect(repr).to eq('2017-12-24 23:59:59')
60
60
  end
61
61
 
62
+ it 'handles a percentage in a formula' do
63
+ repr = roundtrip('((3*4%) * 0.001)')
64
+ expect(repr).to eq('3 * 4% * 0.001')
65
+ end
66
+
62
67
  private
63
68
 
64
69
  def roundtrip(string)
data/spec/spec_helper.rb CHANGED
@@ -1,14 +1,12 @@
1
1
  require 'pry'
2
2
  require 'simplecov'
3
- require 'codecov'
4
3
 
5
4
  SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([
6
5
  SimpleCov::Formatter::HTMLFormatter,
7
- SimpleCov::Formatter::Codecov,
8
6
  ])
9
7
 
10
8
  SimpleCov.minimum_coverage 90
11
- SimpleCov.minimum_coverage_by_file 80
9
+ # SimpleCov.minimum_coverage_by_file 80
12
10
 
13
11
  SimpleCov.start do
14
12
  add_filter "spec/"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dentaku
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.4
4
+ version: 3.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Solomon White
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-08-14 00:00:00.000000000 Z
10
+ date: 2025-10-20 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bigdecimal
@@ -38,20 +37,6 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
- - !ruby/object:Gem::Dependency
42
- name: codecov
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '0'
55
40
  - !ruby/object:Gem::Dependency
56
41
  name: pry
57
42
  requirement: !ruby/object:Gem::Requirement
@@ -157,6 +142,8 @@ executables: []
157
142
  extensions: []
158
143
  extra_rdoc_files: []
159
144
  files:
145
+ - ".github/workflows/rspec.yml"
146
+ - ".github/workflows/rubocop.yml"
160
147
  - ".gitignore"
161
148
  - ".pryrc"
162
149
  - ".rubocop.yml"
@@ -289,7 +276,6 @@ homepage: http://github.com/rubysolo/dentaku
289
276
  licenses:
290
277
  - MIT
291
278
  metadata: {}
292
- post_install_message:
293
279
  rdoc_options: []
294
280
  require_paths:
295
281
  - lib
@@ -304,8 +290,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
304
290
  - !ruby/object:Gem::Version
305
291
  version: '0'
306
292
  requirements: []
307
- rubygems_version: 3.3.9
308
- signing_key:
293
+ rubygems_version: 3.6.2
309
294
  specification_version: 4
310
295
  summary: A formula language parser and evaluator
311
296
  test_files: