dentaku 3.5.7 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/rspec.yml +2 -6
- data/.github/workflows/rubocop.yml +1 -1
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +91 -0
- data/Gemfile +14 -1
- data/README.md +87 -0
- data/dentaku.gemspec +14 -14
- data/lib/dentaku/ast/access.rb +18 -1
- data/lib/dentaku/ast/arithmetic.rb +5 -17
- data/lib/dentaku/ast/array.rb +6 -0
- data/lib/dentaku/ast/bitwise.rb +6 -4
- data/lib/dentaku/ast/case/case_conditional.rb +6 -0
- data/lib/dentaku/ast/case/case_else.rb +6 -0
- data/lib/dentaku/ast/case/case_switch_variable.rb +6 -0
- data/lib/dentaku/ast/case/case_then.rb +6 -0
- data/lib/dentaku/ast/case/case_when.rb +7 -0
- data/lib/dentaku/ast/case.rb +30 -3
- data/lib/dentaku/ast/combinators.rb +49 -4
- data/lib/dentaku/ast/comparators.rb +3 -7
- data/lib/dentaku/ast/function.rb +12 -13
- data/lib/dentaku/ast/function_registry.rb +11 -2
- data/lib/dentaku/ast/functions/abs.rb +1 -1
- data/lib/dentaku/ast/functions/and.rb +4 -4
- data/lib/dentaku/ast/functions/avg.rb +3 -3
- data/lib/dentaku/ast/functions/duration.rb +1 -1
- data/lib/dentaku/ast/functions/if.rb +9 -1
- data/lib/dentaku/ast/functions/intercept.rb +4 -4
- data/lib/dentaku/ast/functions/max.rb +1 -1
- data/lib/dentaku/ast/functions/min.rb +1 -1
- data/lib/dentaku/ast/functions/mul.rb +3 -3
- data/lib/dentaku/ast/functions/or.rb +4 -4
- data/lib/dentaku/ast/functions/pluck.rb +1 -1
- data/lib/dentaku/ast/functions/round.rb +1 -1
- data/lib/dentaku/ast/functions/rounddown.rb +2 -2
- data/lib/dentaku/ast/functions/roundup.rb +2 -2
- data/lib/dentaku/ast/functions/ruby_math.rb +5 -2
- data/lib/dentaku/ast/functions/string_functions.rb +5 -5
- data/lib/dentaku/ast/functions/sum.rb +3 -3
- data/lib/dentaku/ast/functions/xor.rb +4 -4
- data/lib/dentaku/ast/grouping.rb +6 -0
- data/lib/dentaku/ast/negation.rb +5 -0
- data/lib/dentaku/ast/node.rb +25 -0
- data/lib/dentaku/ast/operation.rb +7 -0
- data/lib/dentaku/bulk_expression_solver.rb +22 -16
- data/lib/dentaku/calculator.rb +45 -17
- data/lib/dentaku/date_arithmetic.rb +2 -2
- data/lib/dentaku/dependency_resolver.rb +12 -5
- data/lib/dentaku/exceptions.rb +105 -13
- data/lib/dentaku/numeric_parser.rb +68 -0
- data/lib/dentaku/parser.rb +24 -48
- data/lib/dentaku/print_visitor.rb +1 -0
- data/lib/dentaku/token_matcher.rb +31 -37
- data/lib/dentaku/token_matchers.rb +3 -3
- data/lib/dentaku/token_scanner.rb +3 -4
- data/lib/dentaku/tokenizer.rb +2 -16
- data/lib/dentaku/version.rb +1 -1
- data/spec/ast/case_spec.rb +7 -0
- data/spec/ast/function_spec.rb +11 -25
- data/spec/bulk_expression_solver_spec.rb +48 -3
- data/spec/calculator_spec.rb +77 -0
- data/spec/dependency_resolver_spec.rb +14 -0
- data/spec/exceptions_spec.rb +87 -0
- data/spec/gemspec_spec.rb +10 -0
- data/spec/identifiers_spec.rb +66 -0
- data/spec/numeric_parser_spec.rb +66 -0
- data/spec/parser_spec.rb +35 -0
- data/spec/print_visitor_spec.rb +5 -0
- data/spec/tokenizer_spec.rb +6 -0
- data/spec/volatile_functions_spec.rb +121 -0
- metadata +27 -150
- data/.travis.yml +0 -10
data/lib/dentaku/tokenizer.rb
CHANGED
|
@@ -83,7 +83,7 @@ module Dentaku
|
|
|
83
83
|
|
|
84
84
|
def alias_regex
|
|
85
85
|
values = @aliases.values.flatten.join('|')
|
|
86
|
-
/(?<=\p{Punct}|[[:space:]]|\A)(#{values})(?=\()/i
|
|
86
|
+
/(?<=\p{Punct}|[[:space:]]|\A)(#{values})(?=\s*\()/i
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
private
|
|
@@ -94,21 +94,7 @@ module Dentaku
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def fail!(reason, **meta)
|
|
97
|
-
|
|
98
|
-
case reason
|
|
99
|
-
when :parse_error
|
|
100
|
-
"parse error at: '#{meta.fetch(:at)}'"
|
|
101
|
-
when :too_many_opening_parentheses
|
|
102
|
-
"too many opening parentheses"
|
|
103
|
-
when :too_many_closing_parentheses
|
|
104
|
-
"too many closing parentheses"
|
|
105
|
-
when :unexpected_zero_width_match
|
|
106
|
-
"unexpected zero-width match (:#{meta.fetch(:category)}) at '#{meta.fetch(:at)}'"
|
|
107
|
-
else
|
|
108
|
-
raise ::ArgumentError, "Unhandled #{reason}"
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
raise TokenizerError.for(reason, **meta), message
|
|
97
|
+
raise TokenizerError.for(reason, **meta)
|
|
112
98
|
end
|
|
113
99
|
end
|
|
114
100
|
end
|
data/lib/dentaku/version.rb
CHANGED
data/spec/ast/case_spec.rb
CHANGED
|
@@ -80,5 +80,12 @@ describe Dentaku::AST::Case do
|
|
|
80
80
|
node = described_class.new(switch, conditional1, conditional2, else2)
|
|
81
81
|
expect(node.dependencies).to eq([:fruit, :tax, :fallback])
|
|
82
82
|
end
|
|
83
|
+
|
|
84
|
+
it 'short-circuits to the matched branch when the switch is resolvable' do
|
|
85
|
+
node = described_class.new(switch, conditional1, conditional2, else2)
|
|
86
|
+
expect(node.dependencies(fruit: 'apple')).to eq([])
|
|
87
|
+
expect(node.dependencies(fruit: 'banana')).to eq([:tax])
|
|
88
|
+
expect(node.dependencies(fruit: 'orange')).to eq([:fallback])
|
|
89
|
+
end
|
|
83
90
|
end
|
|
84
91
|
end
|
data/spec/ast/function_spec.rb
CHANGED
|
@@ -37,33 +37,19 @@ describe Dentaku::AST::Function do
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
it 'casts a String to an Integer if possible' do
|
|
41
|
-
expect(described_class.numeric('3')).to eq(3)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it 'casts a String to a BigDecimal if possible and if Integer would loose information' do
|
|
45
|
-
expect(described_class.numeric('3.2')).to eq(3.2)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
it 'casts a String to a BigDecimal with a negative number' do
|
|
49
|
-
expect(described_class.numeric('-3.2')).to eq(-3.2)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
it 'casts a String to a BigDecimal without a leading zero' do
|
|
53
|
-
expect(described_class.numeric('-.2')).to eq(-0.2)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
it 'raises an error if the value could not be cast to a Numeric' do
|
|
57
|
-
expect { described_class.numeric('flarble') }.to raise_error Dentaku::ArgumentError
|
|
58
|
-
expect { described_class.numeric('-') }.to raise_error Dentaku::ArgumentError
|
|
59
|
-
expect { described_class.numeric('') }.to raise_error Dentaku::ArgumentError
|
|
60
|
-
expect { described_class.numeric(nil) }.to raise_error Dentaku::ArgumentError
|
|
61
|
-
expect { described_class.numeric('7.') }.to raise_error Dentaku::ArgumentError
|
|
62
|
-
expect { described_class.numeric(true) }.to raise_error Dentaku::ArgumentError
|
|
63
|
-
end
|
|
64
|
-
|
|
65
40
|
it "allows read access to arguments" do
|
|
66
41
|
fn = described_class.new(1, 2, 3)
|
|
67
42
|
expect(fn.args).to eq([1, 2, 3])
|
|
68
43
|
end
|
|
44
|
+
|
|
45
|
+
describe "#name" do
|
|
46
|
+
it "is a String for functions registered with a Symbol" do
|
|
47
|
+
fn = described_class.register(:symbol_named, :numeric, ->(x) { x })
|
|
48
|
+
expect(fn.name).to eq("symbol_named")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "is a String for functions imported from Ruby's Math module" do
|
|
52
|
+
expect(described_class.get("sqrt").name).to eq("sqrt")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
69
55
|
end
|
|
@@ -130,6 +130,41 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
130
130
|
)
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
it 'resolves mixed-case keys declared out of order when case-sensitive' do
|
|
134
|
+
calculator = Dentaku::Calculator.new(case_sensitive: true)
|
|
135
|
+
expressions = {
|
|
136
|
+
'TopLevel' => 'MidLevel * 2',
|
|
137
|
+
'MidLevel' => 'BottomLevel * 2',
|
|
138
|
+
'BottomLevel' => '2'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
result = described_class.new(expressions, calculator).solve
|
|
142
|
+
|
|
143
|
+
expect(result).to eq(
|
|
144
|
+
'TopLevel' => 8,
|
|
145
|
+
'MidLevel' => 4,
|
|
146
|
+
'BottomLevel' => 2
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'does not expose a downcased alias of a constant when case-sensitive' do
|
|
151
|
+
calculator = Dentaku::Calculator.new(case_sensitive: true)
|
|
152
|
+
expressions = { 'BottomLevel' => '2', 'TopLevel' => 'bottomlevel * 2' }
|
|
153
|
+
|
|
154
|
+
expect {
|
|
155
|
+
described_class.new(expressions, calculator).solve!
|
|
156
|
+
}.to raise_error(Dentaku::UnboundVariableError, /bottomlevel/)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'falls back to mixed-case memory values when case-sensitive' do
|
|
160
|
+
calculator = Dentaku::Calculator.new(case_sensitive: true)
|
|
161
|
+
calculator.store('MyVar' => 7)
|
|
162
|
+
|
|
163
|
+
result = described_class.new({ 'MyVar' => 'Missing + 1' }, calculator).solve
|
|
164
|
+
|
|
165
|
+
expect(result).to eq('MyVar' => 7)
|
|
166
|
+
end
|
|
167
|
+
|
|
133
168
|
it "returns :undefined when variables are unbound" do
|
|
134
169
|
expressions = {more_apples: "apples + 1"}
|
|
135
170
|
expect(described_class.new(expressions, calculator).solve)
|
|
@@ -160,7 +195,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
160
195
|
described_class.new(expressions, calculator).solve do |ex|
|
|
161
196
|
exception = ex
|
|
162
197
|
end
|
|
163
|
-
expect(exception.
|
|
198
|
+
expect(exception.assigned_to).to eq('more_apples')
|
|
164
199
|
end
|
|
165
200
|
|
|
166
201
|
it 'stores the recipient variable on the exception when there is an unbound variable' do
|
|
@@ -169,7 +204,17 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
169
204
|
described_class.new(expressions, calculator).solve do |ex|
|
|
170
205
|
exception = ex
|
|
171
206
|
end
|
|
172
|
-
expect(exception.
|
|
207
|
+
expect(exception.assigned_to).to eq('more_apples')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'sets the recipient variable before the solve block is invoked' do
|
|
211
|
+
expressions = {more_apples: "apples + 1"}
|
|
212
|
+
seen = :unset
|
|
213
|
+
described_class.new(expressions, calculator).solve do |ex|
|
|
214
|
+
seen = ex.assigned_to
|
|
215
|
+
:err
|
|
216
|
+
end
|
|
217
|
+
expect(seen).to eq('more_apples')
|
|
173
218
|
end
|
|
174
219
|
|
|
175
220
|
it 'stores the recipient variable on the exception when there is an ArgumentError' do
|
|
@@ -178,7 +223,7 @@ RSpec.describe Dentaku::BulkExpressionSolver do
|
|
|
178
223
|
described_class.new(expressions, calculator).solve do |ex|
|
|
179
224
|
exception = ex
|
|
180
225
|
end
|
|
181
|
-
expect(exception.
|
|
226
|
+
expect(exception.assigned_to).to eq('more_apples')
|
|
182
227
|
end
|
|
183
228
|
|
|
184
229
|
it 'safely handles argument errors' do
|
data/spec/calculator_spec.rb
CHANGED
|
@@ -195,6 +195,21 @@ describe Dentaku::Calculator do
|
|
|
195
195
|
expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
+
it 'accesses into strings and nested arrays' do
|
|
199
|
+
expect(calculator.evaluate!('a[1]', a: 'abc')).to eq('b')
|
|
200
|
+
expect(calculator.evaluate!('a[1][0]', a: [[1], [2, 3]])).to eq(2)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'raises an argument error when accessing into a non-indexable value' do
|
|
204
|
+
expect { calculator.evaluate!('a[0]', a: 5) }.to raise_error(Dentaku::ArgumentError)
|
|
205
|
+
expect { calculator.evaluate!('a[0]', a: nil) }.to raise_error(Dentaku::ArgumentError)
|
|
206
|
+
expect { calculator.evaluate!('a[0]', a: true) }.to raise_error(Dentaku::ArgumentError)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'raises an argument error when the index type is incompatible' do
|
|
210
|
+
expect { calculator.evaluate!("a['one']", a: [1, 2, 3]) }.to raise_error(Dentaku::ArgumentError)
|
|
211
|
+
end
|
|
212
|
+
|
|
198
213
|
it 'evaluates arrays' do
|
|
199
214
|
expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3])
|
|
200
215
|
expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3])
|
|
@@ -961,10 +976,72 @@ describe Dentaku::Calculator do
|
|
|
961
976
|
end
|
|
962
977
|
end
|
|
963
978
|
|
|
979
|
+
describe 'functions with whitespace before parentheses' do
|
|
980
|
+
it 'accepts regular functions' do
|
|
981
|
+
expect(calculator.evaluate('round (5.1)')).to eq(5)
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
describe 'logical operators with unbound operands' do
|
|
986
|
+
it 'short-circuits OR when the bound operand is true' do
|
|
987
|
+
expect(calculator.evaluate!('a OR b', a: true)).to eq(true)
|
|
988
|
+
expect(calculator.evaluate!('a OR b', b: true)).to eq(true)
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
it 'short-circuits AND when the bound operand is false' do
|
|
992
|
+
expect(calculator.evaluate!('a AND b', a: false)).to eq(false)
|
|
993
|
+
expect(calculator.evaluate!('a AND b', b: false)).to eq(false)
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
it 'still raises when the unbound operand is needed to decide' do
|
|
997
|
+
expect { calculator.evaluate!('a OR b', a: false) }
|
|
998
|
+
.to raise_error(Dentaku::UnboundVariableError)
|
|
999
|
+
expect { calculator.evaluate!('a AND b', a: true) }
|
|
1000
|
+
.to raise_error(Dentaku::UnboundVariableError)
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
964
1004
|
describe 'aliases' do
|
|
965
1005
|
it 'accepts aliases as instance option' do
|
|
966
1006
|
expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
|
|
967
1007
|
end
|
|
1008
|
+
|
|
1009
|
+
it 'accepts aliases with whitespace before parentheses' do
|
|
1010
|
+
expect(with_aliases.evaluate('rrround (5.1)')).to eq(5)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
it 'applies module-level aliases set after calculator creation' do
|
|
1014
|
+
Dentaku.aliases = { round: ['redondear'] }
|
|
1015
|
+
expect(calculator.evaluate('redondear(5.1)')).to eq(5)
|
|
1016
|
+
ensure
|
|
1017
|
+
Dentaku.aliases = {}
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
describe 'constructor options' do
|
|
1022
|
+
it 'raises on unknown options' do
|
|
1023
|
+
expect { described_class.new(case_sensitve: true) }
|
|
1024
|
+
.to raise_error(::ArgumentError, /unknown keyword/)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
it 'accepts a pre-warmed AST cache' do
|
|
1028
|
+
node = calculator.ast('1+1')
|
|
1029
|
+
warmed = described_class.new(ast_cache: { '1+1' => node })
|
|
1030
|
+
expect(warmed.ast_cache).to eq('1+1' => node)
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
it 'caches ASTs when caching is enabled on the instance' do
|
|
1034
|
+
caching = described_class.new(cache_ast: true)
|
|
1035
|
+
caching.ast('1+1')
|
|
1036
|
+
expect(caching.ast_cache.keys).to eq(['1+1'])
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
it 'does not cache ASTs when disabled on the instance, even if enabled globally' do
|
|
1040
|
+
allow(Dentaku).to receive(:cache_ast?) { true }
|
|
1041
|
+
non_caching = described_class.new(cache_ast: false)
|
|
1042
|
+
non_caching.ast('1+1')
|
|
1043
|
+
expect(non_caching.ast_cache).to be_empty
|
|
1044
|
+
end
|
|
968
1045
|
end
|
|
969
1046
|
|
|
970
1047
|
describe 'nested_data' do
|
|
@@ -15,4 +15,18 @@ describe Dentaku::DependencyResolver do
|
|
|
15
15
|
["THIRD", "SeCoNd", "FIRST"]
|
|
16
16
|
)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
it 'sorts mixed-case expressions in dependency order when case-sensitive' do
|
|
20
|
+
dependencies = {"TopLevel" => ["MidLevel"], "MidLevel" => ["BottomLevel"], "BottomLevel" => []}
|
|
21
|
+
expect(described_class.find_resolve_order(dependencies, true)).to eq(
|
|
22
|
+
["BottomLevel", "MidLevel", "TopLevel"]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'treats variables that differ only in case as distinct when case-sensitive' do
|
|
27
|
+
dependencies = {"foo" => ["FOO"], "FOO" => []}
|
|
28
|
+
expect(described_class.find_resolve_order(dependencies, true)).to eq(
|
|
29
|
+
["FOO", "foo"]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
18
32
|
end
|
data/spec/exceptions_spec.rb
CHANGED
|
@@ -7,3 +7,90 @@ describe Dentaku::UnboundVariableError do
|
|
|
7
7
|
expect(exception.unbound_variables).to include('length')
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
describe Dentaku::Error do
|
|
12
|
+
it 'is included in every Dentaku exception, even those subclassing Ruby built-ins' do
|
|
13
|
+
[
|
|
14
|
+
Dentaku::UnboundVariableError.new(['x']),
|
|
15
|
+
Dentaku::MathDomainError.new(:asin, [2]),
|
|
16
|
+
Dentaku::NodeError.new(:numeric, :string, :left),
|
|
17
|
+
Dentaku::ParseError.for(:node_invalid),
|
|
18
|
+
Dentaku::TokenizerError.for(:parse_error),
|
|
19
|
+
Dentaku::ArgumentError.for(:invalid_value),
|
|
20
|
+
Dentaku::ZeroDivisionError.new,
|
|
21
|
+
].each do |exception|
|
|
22
|
+
expect(exception).to be_a(described_class)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'preserves stdlib ancestry for exceptions subclassing Ruby built-ins' do
|
|
27
|
+
expect(Dentaku::ArgumentError.for(:invalid_value)).to be_a(::ArgumentError)
|
|
28
|
+
expect(Dentaku::ZeroDivisionError.new).to be_a(::ZeroDivisionError)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe Dentaku::ParseError do
|
|
33
|
+
it 'builds a message from reason and metadata' do
|
|
34
|
+
exception = described_class.for(:too_few_operands, operation: Dentaku::AST::Addition, expected: 2, actual: 1)
|
|
35
|
+
expect(exception.message).to eq('Dentaku::AST::Addition has too few operands (given 1, expected 2)')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'constructs with sparse metadata' do
|
|
39
|
+
expect(described_class.for(:node_invalid).message).to eq('Node is invalid')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'describes an :incompatible expectation as requiring compatible operands' do
|
|
43
|
+
exception = described_class.for(:node_invalid, operation: Dentaku::AST::Addition, expected: :incompatible, actual: :string)
|
|
44
|
+
expect(exception.message).to eq('Dentaku::AST::Addition requires compatible operands, but got string')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'allows call sites to override the default message' do
|
|
48
|
+
expect {
|
|
49
|
+
raise described_class.for(:node_invalid), 'Case missing switch variable'
|
|
50
|
+
}.to raise_error(described_class, 'Case missing switch variable')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe Dentaku::TokenizerError do
|
|
55
|
+
it 'builds a message from reason and metadata' do
|
|
56
|
+
exception = described_class.for(:parse_error, at: '$5')
|
|
57
|
+
expect(exception.message).to eq("parse error at: '$5'")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe Dentaku::ArgumentError do
|
|
62
|
+
it 'builds an incompatible_type message from a function context' do
|
|
63
|
+
exception = described_class.for(:incompatible_type, function_name: 'AND', expected: :logical, actual: 5)
|
|
64
|
+
expect(exception.message).to eq('AND() requires logical arguments, but got Integer')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'builds an incompatible_type message from expected and actual' do
|
|
68
|
+
exception = described_class.for(:incompatible_type, expected: Integer, actual: 2.5)
|
|
69
|
+
expect(exception.message).to eq('Float is not compatible with Integer')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'builds an invalid_operator message' do
|
|
73
|
+
exception = described_class.for(:invalid_operator, operation: Dentaku::AST::Addition, operator: :+)
|
|
74
|
+
expect(exception.message).to eq('Dentaku::AST::Addition requires operands that respond to +')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'describes an at-least arity as a range' do
|
|
78
|
+
exception = described_class.for(:too_few_arguments, function_name: 'SUM', expected: 1.., actual: 0)
|
|
79
|
+
expect(exception.message).to eq('SUM() has too few arguments (given 0, expected at least 1)')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'describes an exact arity as a count' do
|
|
83
|
+
exception = described_class.for(:wrong_number_of_arguments, function_name: 'INTERCEPT', expected: 2, actual: 4)
|
|
84
|
+
expect(exception.message).to eq('INTERCEPT() has the wrong number of arguments (given 4, expected 2)')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'constructs with sparse metadata' do
|
|
88
|
+
expect(described_class.for(:invalid_value).message).to eq('Invalid value')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'allows call sites to override the default message' do
|
|
92
|
+
expect {
|
|
93
|
+
raise described_class.for(:invalid_value), 'INTERCEPT() requires arrays of equal length'
|
|
94
|
+
}.to raise_error(described_class, 'INTERCEPT() requires arrays of equal length')
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe 'dentaku.gemspec' do
|
|
4
|
+
# tsort no longer ships as a default gem in Ruby 4.1 (PR #334)
|
|
5
|
+
it 'declares tsort as a runtime dependency' do
|
|
6
|
+
gemspec = Gem::Specification.load(File.expand_path('../dentaku.gemspec', __dir__))
|
|
7
|
+
runtime_deps = gemspec.dependencies.select { |d| d.type == :runtime }.map(&:name)
|
|
8
|
+
expect(runtime_deps).to include('tsort')
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'dentaku'
|
|
3
|
+
require 'dentaku/calculator'
|
|
4
|
+
|
|
5
|
+
describe Dentaku::Calculator do
|
|
6
|
+
describe '#identifiers' do
|
|
7
|
+
let(:calculator) { described_class.new }
|
|
8
|
+
|
|
9
|
+
it 'does not prune literal guards' do
|
|
10
|
+
expect(calculator.identifiers('IF(1 > 0, a, b)')).to eq(%w(a b))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'includes the guard identifiers themselves' do
|
|
14
|
+
expect(calculator.identifiers('IF(x > 5, y, z)')).to eq(%w(x y z))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'never executes functions, pure or volatile' do
|
|
18
|
+
count = { pure: 0, volatile: 0 }
|
|
19
|
+
calculator.add_function(:purefn, :numeric, ->(x) { count[:pure] += 1; x })
|
|
20
|
+
calculator.add_function(:volfn, :numeric, ->(x) { count[:volatile] += 1; x }, volatile: true)
|
|
21
|
+
|
|
22
|
+
expect(calculator.identifiers('IF(purefn(1) > 0, a, IF(volfn(2) > 0, b, c))')).to eq(%w(a b c))
|
|
23
|
+
expect(count).to eq(pure: 0, volatile: 0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'includes switch and every branch of CASE' do
|
|
27
|
+
formula = 'CASE fruit WHEN w1 THEN t1 WHEN 2 THEN t2 ELSE e1 END'
|
|
28
|
+
expect(calculator.identifiers(formula)).to eq(%w(fruit w1 t1 t2 e1))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'includes both operands of AND/OR even when one is decisive' do
|
|
32
|
+
expect(calculator.identifiers('1 > 2 AND x')).to eq(['x'])
|
|
33
|
+
expect(calculator.identifiers('a AND b OR c')).to eq(%w(a b c))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'excludes enum-bound variables' do
|
|
37
|
+
expect(calculator.identifiers('MAP(users, u, u.age + bonus)')).to eq(%w(users bonus))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'ignores stored memory' do
|
|
41
|
+
calculator.store(a: 1)
|
|
42
|
+
expect(calculator.identifiers('a + b')).to eq(%w(a b))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'accepts an AST node' do
|
|
46
|
+
node = calculator.ast('x + y')
|
|
47
|
+
expect(calculator.identifiers(node)).to eq(%w(x y))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'accepts an array of expressions and deduplicates' do
|
|
51
|
+
expect(calculator.identifiers(['a + b', 'b + c'])).to eq(%w(a b c))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'deduplicates repeated identifiers' do
|
|
55
|
+
expect(calculator.identifiers('a + a * a')).to eq(['a'])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'lists all inputs of a formula using external-state functions' do
|
|
59
|
+
user_attr = ->(name, default = 0) { default }
|
|
60
|
+
calculator.add_function(:user_attr, :numeric, user_attr)
|
|
61
|
+
|
|
62
|
+
identifiers = calculator.identifiers("IF(user_attr('level') > 50, high_level_value, low_level_value)")
|
|
63
|
+
expect(identifiers).to eq(%w(high_level_value low_level_value))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require 'dentaku/numeric_parser'
|
|
2
|
+
|
|
3
|
+
describe Dentaku::NumericParser do
|
|
4
|
+
it 'casts a String to an Integer if possible' do
|
|
5
|
+
result = Dentaku::NumericParser.ensure_numeric!('3')
|
|
6
|
+
expect(result).to eq(3)
|
|
7
|
+
expect(result).to be_a(Integer)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'casts a String to a BigDecimal if possible and if Integer would loose information' do
|
|
11
|
+
result = Dentaku::NumericParser.ensure_numeric!('3.2')
|
|
12
|
+
expect(result).to eq(3.2)
|
|
13
|
+
expect(result).to be_a(BigDecimal)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'casts a String to a BigDecimal with a negative number' do
|
|
17
|
+
result = Dentaku::NumericParser.ensure_numeric!('-3.2')
|
|
18
|
+
expect(result).to eq(-3.2)
|
|
19
|
+
expect(result).to be_a(BigDecimal)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'casts a String to a BigDecimal without a leading zero' do
|
|
23
|
+
result = Dentaku::NumericParser.ensure_numeric!('-.2')
|
|
24
|
+
expect(result).to eq(-0.2)
|
|
25
|
+
expect(result).to be_a(BigDecimal)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'casts a String with Sientific notiation to a BigDecimal' do
|
|
29
|
+
result = Dentaku::NumericParser.ensure_numeric!('12E1')
|
|
30
|
+
expect(result).to eq(12e1)
|
|
31
|
+
expect(result).to be_a(BigDecimal)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'casts a String with negative Sientific notiation to a BigDecimal' do
|
|
35
|
+
result = Dentaku::NumericParser.ensure_numeric!('12e-2')
|
|
36
|
+
expect(result).to eq(0.12)
|
|
37
|
+
expect(result).to be_a(BigDecimal)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'casts a String with hexadecimal to an Integer' do
|
|
41
|
+
result1 = Dentaku::NumericParser.ensure_numeric!('0xFFD')
|
|
42
|
+
result2 = Dentaku::NumericParser.ensure_numeric!('0x00000001')
|
|
43
|
+
expect(result1).to eq(4093)
|
|
44
|
+
expect(result2).to eq(1)
|
|
45
|
+
expect(result1).to be_a(Integer)
|
|
46
|
+
expect(result2).to be_a(Integer)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'casts a String with a negative hexadecimal to an Integer' do
|
|
50
|
+
result = Dentaku::NumericParser.ensure_numeric!('-0xFF')
|
|
51
|
+
expect(result).to eq(-0xFF)
|
|
52
|
+
expect(result).to be_a(Integer)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'raises an error if the value could not be cast to a Numeric' do
|
|
56
|
+
expect { Dentaku::NumericParser.ensure_numeric!('flarble') }.to raise_error Dentaku::ArgumentError
|
|
57
|
+
expect { Dentaku::NumericParser.ensure_numeric!('-') }.to raise_error Dentaku::ArgumentError
|
|
58
|
+
expect { Dentaku::NumericParser.ensure_numeric!('') }.to raise_error Dentaku::ArgumentError
|
|
59
|
+
expect { Dentaku::NumericParser.ensure_numeric!(nil) }.to raise_error Dentaku::ArgumentError
|
|
60
|
+
expect { Dentaku::NumericParser.ensure_numeric!('7.') }.to raise_error Dentaku::ArgumentError
|
|
61
|
+
expect { Dentaku::NumericParser.ensure_numeric!(true) }.to raise_error Dentaku::ArgumentError
|
|
62
|
+
expect { Dentaku::NumericParser.ensure_numeric!("1 - 2") }.to raise_error Dentaku::ArgumentError
|
|
63
|
+
expect { Dentaku::NumericParser.ensure_numeric!([1]) }.to raise_error Dentaku::ArgumentError
|
|
64
|
+
expect { Dentaku::NumericParser.ensure_numeric!("1f") }.to raise_error Dentaku::ArgumentError
|
|
65
|
+
end
|
|
66
|
+
end
|
data/spec/parser_spec.rb
CHANGED
|
@@ -84,6 +84,17 @@ describe Dentaku::Parser do
|
|
|
84
84
|
expect(node.value("x" => 1, "y" => "A", "Y" => "B")).to eq(3)
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
it 'evaluates a case statement with an unparenthesized operation as switch' do
|
|
88
|
+
node = parse('CASE a % 5 WHEN 0 THEN a ELSE b END')
|
|
89
|
+
expect(node.value("a" => 10, "b" => 1)).to eq(10)
|
|
90
|
+
expect(node.value("a" => 7, "b" => 1)).to eq(1)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'evaluates a case statement with a comparator as switch' do
|
|
94
|
+
node = parse('CASE a > 5 WHEN true THEN a ELSE b END')
|
|
95
|
+
expect(node.value("a" => 10, "b" => 1)).to eq(10)
|
|
96
|
+
end
|
|
97
|
+
|
|
87
98
|
it 'evaluates arrays' do
|
|
88
99
|
node = parse('{}')
|
|
89
100
|
expect(node.value).to eq([])
|
|
@@ -111,6 +122,21 @@ describe Dentaku::Parser do
|
|
|
111
122
|
}.to raise_error(Dentaku::ParseError)
|
|
112
123
|
end
|
|
113
124
|
|
|
125
|
+
it 'preserves the node-level message for incompatible operands' do
|
|
126
|
+
expect {
|
|
127
|
+
parse('"hello" + 1')
|
|
128
|
+
}.to raise_error(
|
|
129
|
+
Dentaku::ParseError,
|
|
130
|
+
/requires operands that are numeric or compatible types, not string/
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'preserves the node-level message for non-logical combinator operands' do
|
|
135
|
+
expect {
|
|
136
|
+
parse('1 AND 2')
|
|
137
|
+
}.to raise_error(Dentaku::ParseError, /requires logical operands/)
|
|
138
|
+
end
|
|
139
|
+
|
|
114
140
|
it 'raises a parse error for too many operands' do
|
|
115
141
|
expect {
|
|
116
142
|
parse("IF(1, 0, IF(1, 2, 3, 4))")
|
|
@@ -167,6 +193,15 @@ describe Dentaku::Parser do
|
|
|
167
193
|
parse("undefined()")
|
|
168
194
|
}.to raise_error(Dentaku::ParseError)
|
|
169
195
|
end
|
|
196
|
+
|
|
197
|
+
it 'raises a parse error for an unbalanced closing parenthesis' do
|
|
198
|
+
# the tokenizer catches unbalanced parentheses in a string expression,
|
|
199
|
+
# so exercise the parser directly with a hand-built token list
|
|
200
|
+
rparen = Dentaku::Token.new(:grouping, :close)
|
|
201
|
+
expect {
|
|
202
|
+
described_class.new([rparen]).parse
|
|
203
|
+
}.to raise_error(Dentaku::ParseError)
|
|
204
|
+
end
|
|
170
205
|
end
|
|
171
206
|
|
|
172
207
|
it "evaluates explicit 'NULL' as nil" do
|
data/spec/print_visitor_spec.rb
CHANGED
|
@@ -49,6 +49,11 @@ describe Dentaku::PrintVisitor do
|
|
|
49
49
|
expect(repr).to eq('CASE a % 5 WHEN 0 THEN a ELSE b END')
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
it 'prints a case statement that can be re-parsed' do
|
|
53
|
+
repr = roundtrip('case (a % 5) when 0 then a else b end')
|
|
54
|
+
expect(roundtrip(repr)).to eq(repr)
|
|
55
|
+
end
|
|
56
|
+
|
|
52
57
|
it 'handles a bitwise operators' do
|
|
53
58
|
repr = roundtrip('0xCAFE & 0xDECAF | 0xBEEF')
|
|
54
59
|
expect(repr).to eq('0xCAFE & 0xDECAF | 0xBEEF')
|
data/spec/tokenizer_spec.rb
CHANGED
|
@@ -360,6 +360,12 @@ describe Dentaku::Tokenizer do
|
|
|
360
360
|
expect(tokenizer.replace_aliases(input)).to eq('min(4,6,2)')
|
|
361
361
|
end
|
|
362
362
|
|
|
363
|
+
it 'replaces aliases with whitespace before parentheses' do
|
|
364
|
+
input = 'rrrrround! (8.2) + minimo (4,6,2)'
|
|
365
|
+
tokenizer.tokenize(input, aliases: aliases)
|
|
366
|
+
expect(tokenizer.replace_aliases(input)).to eq('round (8.2) + min (4,6,2)')
|
|
367
|
+
end
|
|
368
|
+
|
|
363
369
|
it 'replace only whole aliases without word parts' do
|
|
364
370
|
input = 'maximo(2,minimoooo())' # `minimoooo` doesn't match `minimo`
|
|
365
371
|
tokenizer.tokenize(input, aliases: aliases)
|