dentaku 3.5.4 → 3.5.5

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: 42f22227c2d7aef33e71f5c177f65ca3e7855115466d4d04851e6286bcbe1955
4
+ data.tar.gz: e779f142e5f458aed8437539bfe98ee73b4bf86a0ddb280cceec586ebcd62190
5
5
  SHA512:
6
- metadata.gz: 6d616fd597440c13e8432ac6f9d5480592cdcbb7e9aec30c1f2c21e94e0a4bcf3ffced31cf486dc7390bd8668bb5e377745a7eebf467edfab1cd5fa0ae4f70e9
7
- data.tar.gz: fca27bf921a54469f065f634a44d4a6481c6420ccbcaa39400ffe4a5cdd172ff6ae209a04aee96208a64979bbff814635719732b557df33f7d406f87123b1c3a
6
+ metadata.gz: 8c4305e2d7f5c8289edcc5f530dbb0d98cbc95f6db59a8ba98561506f2ec7e41ef89c6ab7271d8754f0a0dfc5ade6325833269f38595593c39eca44c8dcce7c5
7
+ data.tar.gz: 9a186d4164b031bbe27fb1490184d9c23387849267d4b1067c6bec6a39afdee4e6bf97d8bbbb364f36474fdad0e37e59f4ef63dae1454055244bc06f6791905b
@@ -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')
@@ -9,20 +9,6 @@ module Dentaku
9
9
  DECIMAL = /\A-?\d*\.\d+\z/.freeze
10
10
  INTEGER = /\A-?\d+\z/.freeze
11
11
 
12
- def initialize(*)
13
- super
14
-
15
- unless valid_left?
16
- raise NodeError.new(:numeric, left.type, :left),
17
- "#{self.class} requires numeric operands"
18
- end
19
-
20
- unless valid_right?
21
- raise NodeError.new(:numeric, right.type, :right),
22
- "#{self.class} requires numeric operands"
23
- end
24
- end
25
-
26
12
  def type
27
13
  :numeric
28
14
  end
@@ -69,8 +55,9 @@ module Dentaku
69
55
  def datetime?(val)
70
56
  # val is a Date, Time, or DateTime
71
57
  return true if val.respond_to?(:strftime)
58
+ return false unless val.is_a?(::String)
72
59
 
73
- val.to_s =~ Dentaku::TokenScanner::DATE_TIME_REGEXP
60
+ val =~ Dentaku::TokenScanner::DATE_TIME_REGEXP
74
61
  end
75
62
 
76
63
  def valid_node?(node)
@@ -193,6 +180,13 @@ module Dentaku
193
180
  def operator
194
181
  :%
195
182
  end
183
+
184
+ def value(context = {})
185
+ r = decimal(cast(right.value(context)))
186
+ raise Dentaku::ZeroDivisionError if r.zero?
187
+
188
+ cast(cast(left.value(context)) % r)
189
+ end
196
190
  end
197
191
 
198
192
  class Percentage < Arithmetic
@@ -201,26 +195,30 @@ module Dentaku
201
195
  end
202
196
 
203
197
  def initialize(child)
204
- @right = child
198
+ @left = child
205
199
 
206
- unless valid_right?
207
- raise NodeError.new(:numeric, right.type, :right),
200
+ unless valid_left?
201
+ raise NodeError.new(:numeric, left.type, :left),
208
202
  "#{self.class} requires a numeric operand"
209
203
  end
210
204
  end
211
205
 
212
206
  def dependencies(context = {})
213
- @right.dependencies(context)
207
+ @left.dependencies(context)
214
208
  end
215
209
 
216
210
  def value(context = {})
217
- cast(right.value(context)) * 0.01
211
+ cast(left.value(context)) * 0.01
218
212
  end
219
213
 
220
214
  def operator
221
215
  :%
222
216
  end
223
217
 
218
+ def operator_spacing
219
+ ""
220
+ end
221
+
224
222
  def self.precedence
225
223
  30
226
224
  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
 
@@ -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'
@@ -53,17 +53,18 @@ module Dentaku
53
53
  fail! :too_few_operands, operator: operator, expect: expect, actual: output_size
54
54
  end
55
55
 
56
- if output_size > max_size && operations.empty? || args_size > max_size
56
+ if (output_size > max_size && operations.empty?) || args_size > max_size
57
57
  expect = min_size == max_size ? min_size : min_size..max_size
58
58
  fail! :too_many_operands, operator: operator, expect: expect, actual: output_size
59
59
  end
60
60
 
61
+ args = []
61
62
  if operator == AST::Array && output.empty?
62
- output.push(operator.new())
63
+ # special case: empty array literal '{}'
64
+ output.push(operator.new)
63
65
  else
64
66
  fail! :invalid_statement if output_size < args_size
65
67
  args = Array.new(args_size) { output.pop }.reverse
66
-
67
68
  output.push operator.new(*args)
68
69
  end
69
70
 
@@ -79,251 +80,244 @@ module Dentaku
79
80
  def parse
80
81
  return AST::Nil.new if input.empty?
81
82
 
82
- while token = input.shift
83
- case token.category
84
- when :datetime
85
- output.push AST::DateTime.new(token)
83
+ i = 0
84
+ while i < input.length
85
+ token = input[i]
86
+ lookahead = input[i + 1]
87
+ process_token(token, lookahead, i, input)
88
+ i += 1
89
+ end
86
90
 
87
- when :numeric
88
- output.push AST::Numeric.new(token)
91
+ while operations.any?
92
+ consume
93
+ end
89
94
 
90
- when :logical
91
- output.push AST::Logical.new(token)
95
+ unless output.count == 1
96
+ fail! :invalid_statement
97
+ end
92
98
 
93
- when :string
94
- output.push AST::String.new(token)
99
+ output.first
100
+ end
95
101
 
96
- when :identifier
97
- output.push AST::Identifier.new(token, case_sensitive: case_sensitive)
102
+ def operation(token)
103
+ AST_OPERATIONS.fetch(token.value)
104
+ end
98
105
 
99
- when :operator, :comparator, :combinator
100
- op_class = operation(token)
101
- op_class = op_class.resolve_class(input.first)
106
+ def function(token)
107
+ function_registry.get(token.value)
108
+ end
102
109
 
103
- if op_class.right_associative?
104
- while operations.last && operations.last < AST::Operation && op_class.precedence < operations.last.precedence
105
- consume
106
- end
110
+ def function_registry
111
+ @function_registry ||= Dentaku::AST::FunctionRegistry.new
112
+ end
107
113
 
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
114
+ private
113
115
 
114
- operations.push op_class
115
- end
116
+ def process_token(token, lookahead, index, tokens)
117
+ case token.category
118
+ when :datetime then output << AST::DateTime.new(token)
119
+ when :numeric then output << AST::Numeric.new(token)
120
+ when :logical then output << AST::Logical.new(token)
121
+ when :string then output << AST::String.new(token)
122
+ when :identifier then output << AST::Identifier.new(token, case_sensitive: case_sensitive)
123
+ when :operator, :comparator, :combinator
124
+ handle_operator(token, lookahead)
125
+ when :null
126
+ output << AST::Nil.new
127
+ when :function
128
+ handle_function(token)
129
+ when :case
130
+ handle_case(token, index, tokens)
131
+ when :access
132
+ handle_access(token)
133
+ when :array
134
+ handle_array(token)
135
+ when :grouping
136
+ handle_grouping(token, lookahead, tokens)
137
+ else
138
+ fail! :not_implemented_token_category, token_category: token.category
139
+ end
140
+ end
116
141
 
117
- when :null
118
- output.push AST::Nil.new
142
+ def handle_operator(token, lookahead)
143
+ op_class = operation(token).resolve_class(lookahead)
144
+ if op_class.right_associative?
145
+ while operations.last && operations.last < AST::Operation && op_class.precedence < operations.last.precedence
146
+ consume
147
+ end
148
+ else
149
+ while operations.last && operations.last < AST::Operation && op_class.precedence <= operations.last.precedence
150
+ consume
151
+ end
152
+ end
153
+ operations.push op_class
154
+ end
119
155
 
120
- when :function
121
- func = function(token)
122
- if func.nil?
123
- fail! :undefined_function, function_name: token.value
124
- end
156
+ def handle_function(token)
157
+ func = function(token)
158
+ fail! :undefined_function, function_name: token.value if func.nil?
159
+ arities.push 0
160
+ operations.push func
161
+ end
125
162
 
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
163
+ def handle_case(token, index, tokens)
164
+ case_index = operations.index { |o| o == AST::Case } || -1
165
+ token_index = case_index + 1
166
+
167
+ case token.value
168
+ when :open
169
+ if operations.include?(AST::Case)
170
+ # nested case extraction (non-recursive outer parser)
171
+ open_cases = 0
172
+ case_end_index = nil
173
+ j = index + 1
174
+ while j < tokens.length
175
+ t = tokens[j]
176
+ if t.category == :case
177
+ if t.value == :open
178
+ open_cases += 1
179
+ elsif t.value == :close
180
+ if open_cases > 0
181
+ open_cases -= 1
182
+ else
183
+ case_end_index = j
184
+ break
155
185
  end
156
186
  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
187
  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
188
+ j += 1
227
189
  end
190
+ inner_case_inputs = tokens.slice!(index + 1, case_end_index - index) || []
191
+ subparser = Parser.new(
192
+ inner_case_inputs,
193
+ operations: [AST::Case],
194
+ arities: [0],
195
+ function_registry: @function_registry,
196
+ case_sensitive: case_sensitive
197
+ )
198
+ subparser.parse
199
+ output.concat(subparser.output)
200
+ else
201
+ operations.push AST::Case
202
+ arities.push(0)
203
+ end
228
204
 
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
205
+ when :close
206
+ if operations[token_index] == AST::CaseThen
207
+ while operations.last != AST::Case
241
208
  consume
242
209
  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)
210
+ operations.push(AST::CaseConditional)
211
+ consume(2)
212
+ arities[-1] += 1
213
+ elsif operations[token_index] == AST::CaseElse
214
+ while operations.last != AST::Case
215
+ consume
259
216
  end
217
+ arities[-1] += 1
218
+ end
219
+ fail! :unprocessed_token, token_name: token.value unless operations.count >= 1 && operations.last == AST::Case
220
+ consume(arities.pop.succ)
260
221
 
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
222
+ when :when
223
+ if operations[token_index] == AST::CaseThen
224
+ while ![AST::CaseWhen, AST::Case].include?(operations.last)
225
+ consume
226
+ end
227
+ operations.push(AST::CaseConditional)
228
+ consume(2)
229
+ arities[-1] += 1
230
+ elsif operations.last == AST::Case
231
+ operations.push(AST::CaseSwitchVariable)
232
+ consume
233
+ end
234
+ operations.push(AST::CaseWhen)
292
235
 
293
- else
294
- fail! :unknown_grouping_token, token_name: token.value
236
+ when :then
237
+ if operations[token_index] == AST::CaseWhen
238
+ while ![AST::CaseThen, AST::Case].include?(operations.last)
239
+ consume
295
240
  end
241
+ end
242
+ operations.push(AST::CaseThen)
296
243
 
297
- else
298
- fail! :not_implemented_token_category, token_category: token.category
244
+ when :else
245
+ if operations[token_index] == AST::CaseThen
246
+ while operations.last != AST::Case
247
+ consume
248
+ end
249
+ operations.push(AST::CaseConditional)
250
+ consume(2)
251
+ arities[-1] += 1
299
252
  end
253
+ operations.push(AST::CaseElse)
254
+ else
255
+ fail! :unknown_case_token, token_name: token.value
300
256
  end
257
+ end
301
258
 
302
- while operations.any?
259
+ def handle_access(token)
260
+ case token.value
261
+ when :lbracket
262
+ operations.push AST::Access
263
+
264
+ when :rbracket
265
+ while operations.any? && operations.last != AST::Access
266
+ consume
267
+ end
268
+ fail! :unbalanced_bracket, token: token unless operations.last == AST::Access
303
269
  consume
304
270
  end
271
+ end
305
272
 
306
- unless output.count == 1
307
- fail! :invalid_statement
308
- end
273
+ def handle_array(token)
274
+ case token.value
275
+ when :array_start
276
+ operations.push AST::Array
277
+ arities.push 0
309
278
 
310
- output.first
279
+ when :array_end
280
+ while operations.any? && operations.last != AST::Array
281
+ consume
282
+ end
283
+ fail! :unbalanced_bracket, token: token unless operations.last == AST::Array
284
+ consume(arities.pop.succ)
285
+ end
311
286
  end
312
287
 
313
- def operation(token)
314
- AST_OPERATIONS.fetch(token.value)
315
- end
288
+ def handle_grouping(token, lookahead, tokens)
289
+ case token.value
290
+ when :open
291
+ if lookahead && lookahead.value == :close
292
+ # empty grouping (e.g. function with zero arguments) — we trigger consume later
293
+ tokens.delete_at(tokens.index(lookahead)) # remove the close to mimic previous shift behavior
294
+ arities.pop
295
+ consume(0)
296
+ else
297
+ operations.push AST::Grouping
298
+ end
316
299
 
317
- def function(token)
318
- function_registry.get(token.value)
319
- end
300
+ when :close
301
+ while operations.any? && operations.last != AST::Grouping
302
+ consume
303
+ end
304
+ lparen = operations.pop
305
+ fail! :unbalanced_parenthesis, token unless lparen == AST::Grouping
306
+ if operations.last && operations.last < AST::Function
307
+ consume(arities.pop.succ)
308
+ end
320
309
 
321
- def function_registry
322
- @function_registry ||= Dentaku::AST::FunctionRegistry.new
310
+ when :comma
311
+ fail! :invalid_statement if arities.empty?
312
+ arities[-1] += 1
313
+ while operations.any? && operations.last != AST::Grouping && operations.last != AST::Array
314
+ consume
315
+ end
316
+ else
317
+ fail! :unknown_grouping_token, token_name: token.value
318
+ end
323
319
  end
324
320
 
325
- private
326
-
327
321
  def fail!(reason, **meta)
328
322
  message =
329
323
  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.5"
3
3
  end
@@ -83,4 +83,4 @@ module Dentaku
83
83
  end
84
84
  end
85
85
  end
86
- end
86
+ end
@@ -20,22 +20,21 @@ 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
- described_class.new(five, t)
26
- }.to raise_error(Dentaku::NodeError, /requires numeric operands/)
25
+ described_class.new(five, t).value
26
+ }.to raise_error(Dentaku::ArgumentError, /requires operands that respond to +/)
27
27
 
28
28
  expression = Dentaku::AST::Multiplication.new(five, five)
29
29
  group = Dentaku::AST::Grouping.new(expression)
30
30
 
31
31
  expect {
32
- described_class.new(group, five)
32
+ described_class.new(group, five).value
33
33
  }.not_to raise_error
34
34
  end
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,16 +20,16 @@ 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
- described_class.new(five, t)
26
- }.to raise_error(Dentaku::NodeError, /requires numeric operands/)
25
+ described_class.new(five, t).value
26
+ }.to raise_error(Dentaku::ArgumentError, /requires operands that respond to \//)
27
27
 
28
28
  expression = Dentaku::AST::Multiplication.new(five, five)
29
29
  group = Dentaku::AST::Grouping.new(expression)
30
30
 
31
31
  expect {
32
- described_class.new(group, five)
32
+ described_class.new(group, five).value
33
33
  }.not_to raise_error
34
34
  end
35
35
 
@@ -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))
@@ -531,7 +531,7 @@ describe Dentaku::Calculator do
531
531
  expect(calculator.evaluate!('value + duration(1, month)', { value: value })).to eq(Time.local(2023, 8, 13, 10, 42, 11))
532
532
  expect(calculator.evaluate!('value - duration(1, day)', { value: value })).to eq(Time.local(2023, 7, 12, 10, 42, 11))
533
533
  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)
534
+ expect(calculator.evaluate!('value2 - value', { value: value, value2: value2 })).to eq(value2 - value)
535
535
  expect(calculator.evaluate!('value - 7200', { value: value })).to eq(Time.local(2023, 7, 13, 8, 42, 11))
536
536
  end
537
537
  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.5
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-08-21 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: