dentaku 4.0.0 → 4.0.1
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/CHANGELOG.md +34 -0
- data/README.md +36 -0
- data/lib/dentaku/ast/arithmetic.rb +20 -6
- data/lib/dentaku/ast/combinators.rb +3 -1
- data/lib/dentaku/ast/function_registry.rb +18 -1
- data/lib/dentaku/ast/negation.rb +3 -1
- data/lib/dentaku/calculator.rb +38 -17
- data/lib/dentaku/version.rb +1 -1
- data/spec/ast/arithmetic_spec.rb +93 -0
- data/spec/calculator_spec.rb +79 -0
- data/spec/external_function_spec.rb +33 -0
- data/spec/spec_helper.rb +9 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c52e5ed43e8cc6d9ac6f3cf81ae522e408860ab24bf59109bdd97301e0d6e12
|
|
4
|
+
data.tar.gz: fa376e35d073d5c0ebce858f96c4c1322ed1bba2a202dc6e41d265c1e5fa24f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 040e836d96fad9833095285a2d3a9b6b7e7a3c4bffcd84cac9603bc16128308acb719103eb96693cc04910c028265231f72f982658dd34523d3685ac4dea8e7d
|
|
7
|
+
data.tar.gz: 66aa9ee21ce77b2d9dd25d44b3a7f1db7212e7776614839d4c92e403ed0d4e5561fee07e28a50eb81c1a886bf889c1ae285c4a89aa78a59910f5636de66159d4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## [v4.0.1] 2026-08-02
|
|
4
|
+
- parsing no longer executes user functions. The three parse-time operand
|
|
5
|
+
validators (`Arithmetic`, `Negation`, and the logical combinators) asked
|
|
6
|
+
each operand for its dependencies without static mode, which evaluates
|
|
7
|
+
short-circuit guards -- so an `IF` or `CASE` used as an operand ran its
|
|
8
|
+
predicate while the parser was still building the node. `Calculator#ast`
|
|
9
|
+
and `Calculator#identifiers` could therefore raise from inside a
|
|
10
|
+
user-registered function, contrary to the documented contract that
|
|
11
|
+
`identifiers` never evaluates guards or functions (#197). Regression
|
|
12
|
+
introduced in 3.5.6 (ecd46bf); evaluation and short-circuiting during
|
|
13
|
+
`evaluate`/`dependencies` are unchanged.
|
|
14
|
+
- functions registered under a name that another calculator already registered
|
|
15
|
+
no longer render as `#<Class:0x...>` in error messages; every generated
|
|
16
|
+
function class now has a stable `to_s` of
|
|
17
|
+
`Dentaku::AST::Function::<Name>` (#264)
|
|
18
|
+
- arithmetic operations that reject an otherwise well-typed operand (notably
|
|
19
|
+
`^` with an oversized exponent) now raise `Dentaku::ArgumentError` instead of
|
|
20
|
+
leaking a raw Ruby `ArgumentError`, so the non-bang `Calculator#evaluate`
|
|
21
|
+
returns `nil` as documented rather than raising (#332)
|
|
22
|
+
- `Calculator#store` restores only the keys it set instead of snapshotting the
|
|
23
|
+
whole memory hash, so evaluation cost no longer scales with the number of
|
|
24
|
+
stored variables (#336). Measured on 20k evaluations of a two-variable
|
|
25
|
+
formula: 34.5 -> 18.6 us/eval with 20,000 variables in memory, and flat
|
|
26
|
+
across memory sizes. Block-scoping semantics are unchanged; when the
|
|
27
|
+
identifier cache is enabled the previous whole-hash snapshot is still used,
|
|
28
|
+
because evaluation writes into memory in that mode.
|
|
29
|
+
- document what the `add_function` return type argument actually does: it is
|
|
30
|
+
consulted at parse time to decide whether a call is a valid arithmetic
|
|
31
|
+
operand, is never coerced or checked at runtime, and has no observable
|
|
32
|
+
effect outside arithmetic (#303)
|
|
33
|
+
- the test suite no longer depends on example ordering: the module-level
|
|
34
|
+
caching opt-ins are reset before each example, since they have no public
|
|
35
|
+
disable and leaked between examples
|
|
36
|
+
|
|
3
37
|
## [v4.0.0] 2026-08-01
|
|
4
38
|
BREAKING CHANGES
|
|
5
39
|
- require Ruby 3.2 or newer
|
data/README.md
CHANGED
|
@@ -347,6 +347,42 @@ function)
|
|
|
347
347
|
Functions can be added individually using Calculator#add_function, or en masse
|
|
348
348
|
using Calculator#add_functions.
|
|
349
349
|
|
|
350
|
+
### The return type argument
|
|
351
|
+
|
|
352
|
+
The second argument is the function's *declared* return type. It is used at
|
|
353
|
+
parse time to decide whether a call to the function is a valid operand of an
|
|
354
|
+
arithmetic operator -- only `:numeric`, `:integer`, `:array`, and `nil` are
|
|
355
|
+
accepted there. Declaring anything else means the function cannot be used in
|
|
356
|
+
arithmetic:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
> c = Dentaku::Calculator.new
|
|
360
|
+
> c.add_function(:label, :string, ->(x) { "item#{x}" })
|
|
361
|
+
> c.evaluate!('label(1)')
|
|
362
|
+
#=> "item1"
|
|
363
|
+
> c.evaluate!("label(1) = 'item1'") # comparison: type is not consulted
|
|
364
|
+
#=> true
|
|
365
|
+
> c.evaluate!('label(1) + 1') # arithmetic: rejected at parse time
|
|
366
|
+
#=> Dentaku::ParseError: Dentaku::AST::Addition requires operands that are
|
|
367
|
+
# numeric or compatible types, not string
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The type is a declaration, not a guarantee: Dentaku does not coerce the return
|
|
371
|
+
value and does not check it at runtime. Declaring `:numeric` for a function
|
|
372
|
+
that actually returns a String defers the failure to whatever consumes it:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
> c.add_function(:mislabeled, :numeric, ->(x) { "item#{x}" })
|
|
376
|
+
> c.evaluate!('mislabeled(1) + 1')
|
|
377
|
+
#=> Dentaku::ArgumentError: String input 'item1' is not coercible to numeric
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
So the type only has an observable effect when the result feeds an arithmetic
|
|
381
|
+
operator. A function used standalone, in comparisons, or as a function
|
|
382
|
+
argument behaves the same whatever type is declared -- which is why an
|
|
383
|
+
incorrect type can go unnoticed for a long time. Declare the type your lambda
|
|
384
|
+
actually returns.
|
|
385
|
+
|
|
350
386
|
Dentaku assumes registered functions are pure (see DEPENDENCY ANALYSIS AND
|
|
351
387
|
SHORT-CIRCUITING above). A function that reads external state, performs
|
|
352
388
|
I/O, or returns different values across calls must be declared volatile so
|
|
@@ -38,10 +38,21 @@ module Dentaku
|
|
|
38
38
|
l = cast(left_value)
|
|
39
39
|
r = cast(right_value)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
begin
|
|
42
|
+
l.public_send(operator, r)
|
|
43
|
+
rescue ::TypeError => e
|
|
44
|
+
# Right cannot be converted to a suitable type for left. e.g. [] + 1
|
|
45
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
|
|
46
|
+
rescue Dentaku::Error
|
|
47
|
+
# already one of ours (e.g. from arithmetic on a custom class)
|
|
48
|
+
raise
|
|
49
|
+
rescue ::ArgumentError, ::RangeError, ::FloatDomainError => e
|
|
50
|
+
# the operation itself rejected an otherwise well-typed operand,
|
|
51
|
+
# e.g. BigDecimal#** with an oversized exponent. Without this, a raw
|
|
52
|
+
# Ruby exception escapes the non-bang Calculator#evaluate, which
|
|
53
|
+
# promises to return nil rather than raise.
|
|
54
|
+
raise Dentaku::ArgumentError.for(:invalid_value, actual: r), e.message
|
|
55
|
+
end
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
def cast(val)
|
|
@@ -66,8 +77,11 @@ module Dentaku
|
|
|
66
77
|
def valid_node?(node)
|
|
67
78
|
return false unless node
|
|
68
79
|
|
|
69
|
-
# Allow nodes with dependencies (identifiers that will be resolved later)
|
|
70
|
-
|
|
80
|
+
# Allow nodes with dependencies (identifiers that will be resolved later).
|
|
81
|
+
# Ask statically: this runs while the parser is building the node, and
|
|
82
|
+
# a non-static dependency check evaluates short-circuit guards, which
|
|
83
|
+
# would execute user functions at parse time (#197).
|
|
84
|
+
return true if node.dependencies(Node::STATIC_CONTEXT).any?
|
|
71
85
|
|
|
72
86
|
# Allow compatible types
|
|
73
87
|
return true if [:numeric, :integer, :array].include?(node.type)
|
|
@@ -57,8 +57,10 @@ module Dentaku
|
|
|
57
57
|
|
|
58
58
|
private
|
|
59
59
|
|
|
60
|
+
# static dependency check: this runs at parse time, and evaluating
|
|
61
|
+
# short-circuit guards here would execute user functions (#197)
|
|
60
62
|
def valid_node?(node)
|
|
61
|
-
node && (node.dependencies.any? || node.type == :logical)
|
|
63
|
+
node && (node.dependencies(Node::STATIC_CONTEXT).any? || node.type == :logical)
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
# whether a single operand with this value already determines the
|
|
@@ -18,6 +18,21 @@ module Dentaku
|
|
|
18
18
|
@name
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# only the first registration of a given name claims the constant
|
|
22
|
+
# under Dentaku::AST::Function, so later ones stay anonymous and
|
|
23
|
+
# would otherwise render as "#<Class:0x...>" in error messages
|
|
24
|
+
def self.display_name=(display_name)
|
|
25
|
+
@display_name = display_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.to_s
|
|
29
|
+
@display_name || super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.inspect
|
|
33
|
+
to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
21
36
|
def self.implementation=(impl)
|
|
22
37
|
@implementation = impl
|
|
23
38
|
end
|
|
@@ -72,9 +87,11 @@ module Dentaku
|
|
|
72
87
|
end
|
|
73
88
|
end
|
|
74
89
|
|
|
90
|
+
function.name = name
|
|
91
|
+
function.display_name = "#{Function}::#{normalize_name(name)}"
|
|
92
|
+
|
|
75
93
|
define_class(name, function)
|
|
76
94
|
|
|
77
|
-
function.name = name
|
|
78
95
|
function.type = type
|
|
79
96
|
function.implementation = implementation
|
|
80
97
|
function.callback = callback
|
data/lib/dentaku/ast/negation.rb
CHANGED
|
@@ -51,8 +51,10 @@ module Dentaku
|
|
|
51
51
|
@node.pure?
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# static dependency check: this runs at parse time, and evaluating
|
|
55
|
+
# short-circuit guards here would execute user functions (#197)
|
|
54
56
|
def valid_node?(node)
|
|
55
|
-
node && (node.dependencies.any? || node.type == :numeric)
|
|
57
|
+
node && (node.dependencies(Node::STATIC_CONTEXT).any? || node.type == :numeric)
|
|
56
58
|
end
|
|
57
59
|
end
|
|
58
60
|
end
|
data/lib/dentaku/calculator.rb
CHANGED
|
@@ -102,7 +102,9 @@ module Dentaku
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def dependencies(expression, context = {})
|
|
105
|
-
|
|
105
|
+
# dup inside the block: `store` now restores in place, so the caller
|
|
106
|
+
# needs its own copy of the merged context rather than the live memory
|
|
107
|
+
test_context = context.nil? ? {} : store(context) { memory.dup }
|
|
106
108
|
|
|
107
109
|
case expression
|
|
108
110
|
when Dentaku::AST::Node
|
|
@@ -169,32 +171,51 @@ module Dentaku
|
|
|
169
171
|
end
|
|
170
172
|
|
|
171
173
|
def store(key_or_hash, value = nil)
|
|
172
|
-
|
|
174
|
+
pairs = pairs_to_store(key_or_hash, value)
|
|
173
175
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
memory[standardize_case(key.to_s)] = val
|
|
178
|
-
end
|
|
179
|
-
else
|
|
180
|
-
memory[standardize_case(key_or_hash.to_s)] = value
|
|
176
|
+
unless block_given?
|
|
177
|
+
pairs.each { |key, val| memory[key] = val }
|
|
178
|
+
return self
|
|
181
179
|
end
|
|
182
180
|
|
|
183
|
-
|
|
181
|
+
# `evaluate!` routes every call through here, so snapshotting the whole
|
|
182
|
+
# memory hash makes each evaluation scale with the number of stored
|
|
183
|
+
# variables (#336). Undoing just the keys this call touched is O(pairs)
|
|
184
|
+
# instead of O(memory) -- but it is only equivalent while nothing else
|
|
185
|
+
# writes into memory during the block. The identifier cache does exactly
|
|
186
|
+
# that, so when it is enabled we still need the wholesale snapshot to
|
|
187
|
+
# keep cached values scoped to a single evaluation.
|
|
188
|
+
if Dentaku.cache_identifier?
|
|
189
|
+
restore = Hash[memory]
|
|
190
|
+
pairs.each { |key, val| memory[key] = val }
|
|
191
|
+
|
|
184
192
|
begin
|
|
185
|
-
|
|
193
|
+
yield
|
|
194
|
+
ensure
|
|
186
195
|
@memory = restore
|
|
187
|
-
return result
|
|
188
|
-
rescue => e
|
|
189
|
-
@memory = restore
|
|
190
|
-
raise e
|
|
191
196
|
end
|
|
192
|
-
|
|
197
|
+
else
|
|
198
|
+
undo = pairs.map { |key, _| [key, memory.key?(key), memory[key]] }
|
|
199
|
+
pairs.each { |key, val| memory[key] = val }
|
|
193
200
|
|
|
194
|
-
|
|
201
|
+
begin
|
|
202
|
+
yield
|
|
203
|
+
ensure
|
|
204
|
+
undo.each { |key, present, val| present ? memory[key] = val : memory.delete(key) }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
195
207
|
end
|
|
196
208
|
alias_method :bind, :store
|
|
197
209
|
|
|
210
|
+
private def pairs_to_store(key_or_hash, value)
|
|
211
|
+
if value.nil?
|
|
212
|
+
key_or_hash = FlatHash.from_hash_with_intermediates(key_or_hash) if nested_data_support
|
|
213
|
+
key_or_hash.map { |key, val| [standardize_case(key.to_s), val] }
|
|
214
|
+
else
|
|
215
|
+
[[standardize_case(key_or_hash.to_s), value]]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
198
219
|
def store_formula(key, formula)
|
|
199
220
|
store(key, ast(formula))
|
|
200
221
|
end
|
data/lib/dentaku/version.rb
CHANGED
data/spec/ast/arithmetic_spec.rb
CHANGED
|
@@ -123,6 +123,99 @@ describe Dentaku::AST::Arithmetic do
|
|
|
123
123
|
expect { add(x, one, 'x' => [1]) }.to raise_error(Dentaku::ArgumentError)
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
describe 'parse-time operand validation (#197)' do
|
|
127
|
+
let(:calculator) do
|
|
128
|
+
Dentaku::Calculator.new.tap do |c|
|
|
129
|
+
c.add_function(:explode, :numeric, ->(*) { raise "function executed at parse time" })
|
|
130
|
+
c.add_function(:explode_flag, :logical, ->(*) { raise "function executed at parse time" })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'does not execute functions when an IF is an arithmetic operand' do
|
|
135
|
+
expect {
|
|
136
|
+
calculator.ast("IF(explode(1) = 1, 1, 2) + category")
|
|
137
|
+
}.not_to raise_error
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'does not execute functions when an IF is negated' do
|
|
141
|
+
expect {
|
|
142
|
+
calculator.ast("-IF(explode(1) = 1, 1, 2)")
|
|
143
|
+
}.not_to raise_error
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'does not execute functions when an IF is a combinator operand' do
|
|
147
|
+
expect {
|
|
148
|
+
calculator.ast("IF(explode_flag(), true, false) AND x")
|
|
149
|
+
}.not_to raise_error
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'does not execute functions when a CASE is an arithmetic operand' do
|
|
153
|
+
expect {
|
|
154
|
+
calculator.ast("(CASE explode(1) WHEN 1 THEN 1 ELSE 2 END) + category")
|
|
155
|
+
}.not_to raise_error
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'reports identifiers across both branches without evaluating' do
|
|
159
|
+
expect(calculator.identifiers("IF(explode(1) = 1, 1, 2) + category")).to eq(["category"])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'still validates operand types' do
|
|
163
|
+
expect { Dentaku::Calculator.new.ast("'abc' + 1") }.to raise_error(Dentaku::ParseError)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'leaves evaluation and short-circuiting unchanged' do
|
|
167
|
+
plain = Dentaku::Calculator.new
|
|
168
|
+
|
|
169
|
+
expect(plain.evaluate!("IF(a = 1, 10, 20) + 5", a: 1)).to eq(15)
|
|
170
|
+
expect(plain.evaluate!("IF(a = 1, 10, 20) + 5", a: 2)).to eq(25)
|
|
171
|
+
expect(plain.evaluate!("-IF(a = 1, 10, 20)", a: 1)).to eq(-10)
|
|
172
|
+
expect(plain.dependencies("IF(a, b, c)", a: true)).to eq(["b"])
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe 'operations that reject well-typed operands (#332)' do
|
|
177
|
+
let(:oversized) { "999999999999999 ^ 99999999999999" }
|
|
178
|
+
|
|
179
|
+
it 'wraps the raw Ruby error so it is a Dentaku::Error' do
|
|
180
|
+
expect {
|
|
181
|
+
Dentaku::Calculator.new.evaluate!(oversized)
|
|
182
|
+
}.to raise_error(Dentaku::ArgumentError, /exponent is too large/)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'is rescuable as Dentaku::Error' do
|
|
186
|
+
error = begin
|
|
187
|
+
Dentaku::Calculator.new.evaluate!(oversized)
|
|
188
|
+
nil
|
|
189
|
+
rescue Dentaku::Error => e
|
|
190
|
+
e
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
expect(error).to be_a(Dentaku::ArgumentError)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'returns nil from the non-bang evaluate rather than raising' do
|
|
197
|
+
expect(Dentaku::Calculator.new.evaluate(oversized)).to be_nil
|
|
198
|
+
expect(Dentaku::Calculator.new.evaluate("2 ^ 99999999999")).to be_nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'yields to the block form' do
|
|
202
|
+
handled = nil
|
|
203
|
+
Dentaku::Calculator.new.evaluate(oversized) { |_expr, ex| handled = ex }
|
|
204
|
+
|
|
205
|
+
expect(handled).to be_a(Dentaku::ArgumentError)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'still evaluates exponentiation that fits' do
|
|
209
|
+
expect(Dentaku::Calculator.new.evaluate!("2 ^ 10")).to eq(1024)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'does not swallow Dentaku errors raised by the operation itself' do
|
|
213
|
+
expect {
|
|
214
|
+
Dentaku::Calculator.new.evaluate!("1 / 0")
|
|
215
|
+
}.to raise_error(Dentaku::ZeroDivisionError)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
126
219
|
private
|
|
127
220
|
|
|
128
221
|
def add(left, right, context = ctx)
|
data/spec/calculator_spec.rb
CHANGED
|
@@ -1076,5 +1076,84 @@ describe Dentaku::Calculator do
|
|
|
1076
1076
|
called
|
|
1077
1077
|
}.from(12).to(1)
|
|
1078
1078
|
end
|
|
1079
|
+
|
|
1080
|
+
it 'does not carry cached identifier values across evaluations' do
|
|
1081
|
+
# there is no public disable, so save/restore around the example
|
|
1082
|
+
was_enabled = Dentaku.cache_identifier?
|
|
1083
|
+
Dentaku.enable_identifier_cache!
|
|
1084
|
+
called = 0
|
|
1085
|
+
calculator.store_formula("A1", "B1+B1")
|
|
1086
|
+
calculator.store_formula("B1", "C1")
|
|
1087
|
+
calculator.store("C1", proc { called += 1; 1 })
|
|
1088
|
+
|
|
1089
|
+
calculator.evaluate("A1")
|
|
1090
|
+
after_first = called
|
|
1091
|
+
calculator.evaluate("A1")
|
|
1092
|
+
|
|
1093
|
+
expect(after_first).to be > 0
|
|
1094
|
+
expect(called).to eq(after_first * 2)
|
|
1095
|
+
ensure
|
|
1096
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, was_enabled)
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
describe 'block-scoped store (#336)' do
|
|
1101
|
+
it 'removes keys the block introduced' do
|
|
1102
|
+
calculator.store(a: 1)
|
|
1103
|
+
result = calculator.store(b: 2) { calculator.evaluate!("a + b") }
|
|
1104
|
+
|
|
1105
|
+
expect(result).to eq(3)
|
|
1106
|
+
expect(calculator.memory.keys).to eq(["a"])
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
it 'restores keys the block shadowed' do
|
|
1110
|
+
calculator.store(a: 1)
|
|
1111
|
+
inner = calculator.store(a: 99) { calculator.evaluate!("a") }
|
|
1112
|
+
|
|
1113
|
+
expect(inner).to eq(99)
|
|
1114
|
+
expect(calculator.evaluate!("a")).to eq(1)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
it 'restores when the block raises' do
|
|
1118
|
+
calculator.store(a: 1)
|
|
1119
|
+
|
|
1120
|
+
expect {
|
|
1121
|
+
calculator.store(a: 99) { raise "boom" }
|
|
1122
|
+
}.to raise_error("boom")
|
|
1123
|
+
|
|
1124
|
+
expect(calculator.evaluate!("a")).to eq(1)
|
|
1125
|
+
expect(calculator.memory.keys).to eq(["a"])
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
it 'restores correctly when nested' do
|
|
1129
|
+
calculator.store(a: 1)
|
|
1130
|
+
innermost = calculator.store(a: 2) { calculator.store(a: 3) { calculator.evaluate!("a") } }
|
|
1131
|
+
|
|
1132
|
+
expect(innermost).to eq(3)
|
|
1133
|
+
expect(calculator.evaluate!("a")).to eq(1)
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
it 'does not leak the internal evaluation mode key into memory' do
|
|
1137
|
+
calculator.store(a: 1)
|
|
1138
|
+
calculator.evaluate("a + 1")
|
|
1139
|
+
|
|
1140
|
+
expect(calculator.memory.keys).to eq(["a"])
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
it 'restores in place rather than copying the whole memory hash' do
|
|
1144
|
+
# the old implementation snapshotted memory up front and reassigned
|
|
1145
|
+
# @memory to the snapshot, so evaluation cost scaled with memory size.
|
|
1146
|
+
# Restoring in place keeps the same hash object throughout.
|
|
1147
|
+
was_enabled = Dentaku.cache_identifier?
|
|
1148
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, false)
|
|
1149
|
+
|
|
1150
|
+
calculator.store(a: 1, b: 2)
|
|
1151
|
+
before = calculator.memory.object_id
|
|
1152
|
+
|
|
1153
|
+
expect(calculator.evaluate!("a + b")).to eq(3)
|
|
1154
|
+
expect(calculator.memory.object_id).to eq(before)
|
|
1155
|
+
ensure
|
|
1156
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, was_enabled)
|
|
1157
|
+
end
|
|
1079
1158
|
end
|
|
1080
1159
|
end
|
|
@@ -109,6 +109,7 @@ describe Dentaku::Calculator do
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
it 'exposes the `callback` method of a function' do
|
|
112
|
+
custom_calculator # the `let` is lazy, and it is what registers the function
|
|
112
113
|
expect(Dentaku::AST::Function::Callback_lambda.callback.call()).to eq("lambda executed")
|
|
113
114
|
end
|
|
114
115
|
|
|
@@ -155,6 +156,38 @@ describe Dentaku::Calculator do
|
|
|
155
156
|
}.to raise_error(Dentaku::ParseError)
|
|
156
157
|
end
|
|
157
158
|
|
|
159
|
+
describe 'error messages (#264)' do
|
|
160
|
+
it 'names the function even when another calculator claimed the constant' do
|
|
161
|
+
messages = 2.times.map do
|
|
162
|
+
calculator = described_class.new
|
|
163
|
+
calculator.add_function(:custom, :integer, -> { 1 })
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
calculator.ast("CUSTOM(1,2)")
|
|
167
|
+
nil
|
|
168
|
+
rescue Dentaku::ParseError => e
|
|
169
|
+
e.message
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
expect(messages).to all(include("Dentaku::AST::Function::Custom"))
|
|
174
|
+
messages.each { |message| expect(message).not_to include("#<Class:") }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'gives re-registered functions a stable string representation' do
|
|
178
|
+
first = described_class.new
|
|
179
|
+
first.add_function(:repeated, :numeric, ->(x) { x })
|
|
180
|
+
second = described_class.new
|
|
181
|
+
second.add_function(:repeated, :numeric, ->(x) { x * 2 })
|
|
182
|
+
|
|
183
|
+
registry = ->(c) { c.instance_variable_get(:@function_registry).get("repeated") }
|
|
184
|
+
|
|
185
|
+
expect(registry.call(second).to_s).to eq("Dentaku::AST::Function::Repeated")
|
|
186
|
+
expect(registry.call(second).to_s).to eq(registry.call(first).to_s)
|
|
187
|
+
expect(registry.call(second).name).to eq("repeated")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
158
191
|
describe 'Dentaku::Calculator.add_function' do
|
|
159
192
|
it 'adds a function to default/global function registry' do
|
|
160
193
|
described_class.add_function(:global_function, :numeric, ->(x) { 10 + x**2 })
|
data/spec/spec_helper.rb
CHANGED
|
@@ -20,6 +20,15 @@ RSpec.configure do |c|
|
|
|
20
20
|
Dentaku.aliases = { roundup: ['roundupup'] }
|
|
21
21
|
end
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
# the caching opt-ins are module-level and have no public "disable", so an
|
|
25
|
+
# example that turns one on leaks into every example that runs after it.
|
|
26
|
+
# Reset to the documented defaults so the suite does not depend on order.
|
|
27
|
+
c.before(:each) {
|
|
28
|
+
Dentaku.instance_variable_set(:@enable_ast_caching, false)
|
|
29
|
+
Dentaku.instance_variable_set(:@enable_dependency_order_caching, false)
|
|
30
|
+
Dentaku.instance_variable_set(:@enable_identifier_caching, false)
|
|
31
|
+
}
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
# automatically create a token stream from bare values
|