dentaku 3.5.8 → 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 -4
- data/.github/workflows/rubocop.yml +1 -1
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +74 -1
- data/Gemfile +1 -1
- data/README.md +87 -0
- data/dentaku.gemspec +15 -8
- data/lib/dentaku/ast/access.rb +18 -1
- data/lib/dentaku/ast/arithmetic.rb +3 -4
- 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 +2 -3
- data/lib/dentaku/ast/function.rb +15 -2
- data/lib/dentaku/ast/function_registry.rb +10 -1
- data/lib/dentaku/ast/functions/and.rb +4 -4
- data/lib/dentaku/ast/functions/avg.rb +2 -2
- 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 +2 -2
- data/lib/dentaku/ast/functions/mul.rb +2 -2
- data/lib/dentaku/ast/functions/or.rb +4 -4
- data/lib/dentaku/ast/functions/pluck.rb +1 -1
- data/lib/dentaku/ast/functions/string_functions.rb +1 -1
- data/lib/dentaku/ast/functions/sum.rb +2 -2
- 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 +9 -10
- data/lib/dentaku/calculator.rb +45 -17
- data/lib/dentaku/date_arithmetic.rb +2 -2
- data/lib/dentaku/exceptions.rb +105 -13
- data/lib/dentaku/numeric_parser.rb +1 -1
- data/lib/dentaku/parser.rb +24 -48
- data/lib/dentaku/token_matcher.rb +31 -37
- data/lib/dentaku/token_matchers.rb +3 -3
- data/lib/dentaku/tokenizer.rb +1 -15
- data/lib/dentaku/version.rb +1 -1
- data/spec/ast/case_spec.rb +7 -0
- data/spec/bulk_expression_solver_spec.rb +13 -3
- data/spec/calculator_spec.rb +67 -0
- data/spec/exceptions_spec.rb +87 -0
- data/spec/gemspec_spec.rb +10 -0
- data/spec/identifiers_spec.rb +66 -0
- data/spec/parser_spec.rb +35 -0
- data/spec/print_visitor_spec.rb +5 -0
- data/spec/volatile_functions_spec.rb +121 -0
- metadata +21 -63
- data/.travis.yml +0 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a911d2d58c12144c0692b27a9abe5ad08716e1800deb9e9bda08bee04735b9ac
|
|
4
|
+
data.tar.gz: 8e55f39625ebb6a6a5f4d38b820137566c9bd2d529d411cb4c40c89770dbf048
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac372c840c68d33bb1a6577640ea17812e0aab7b1f82c1979d1caac5ef670148743ecc77f87c8cf32a33e5e90d6f9f35f5d6da85da3e345beb64cf0a0d7d2b78
|
|
7
|
+
data.tar.gz: 4d9d05f5b7a79751cfc4f82576b6bddd5d621e02df4ee98282052d55cbe87c250d5f0906960726278ee078d9162f369a9efd42f166d7446d189aeb80de7c166d
|
data/.github/workflows/rspec.yml
CHANGED
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [v4.0.0] 2026-08-01
|
|
4
|
+
BREAKING CHANGES
|
|
5
|
+
- require Ruby 3.2 or newer
|
|
6
|
+
- `Calculator.new` takes keyword arguments; the AST cache must now be passed
|
|
7
|
+
explicitly via `ast_cache:` instead of as leftover options, unknown options
|
|
8
|
+
raise `ArgumentError`, and the passed values are no longer mutated
|
|
9
|
+
- `Dentaku::Error` is now a module included by all Dentaku exceptions, so
|
|
10
|
+
`rescue Dentaku::Error` also catches `Dentaku::ArgumentError` and
|
|
11
|
+
`Dentaku::ZeroDivisionError`; exceptions that previously subclassed
|
|
12
|
+
`Dentaku::Error` now subclass `Dentaku::BaseError`
|
|
13
|
+
- unify exception metadata naming: the "required vs. received" pair is now
|
|
14
|
+
`expected:` / `actual:` everywhere (previously a mix of `expect:`/`actual:`,
|
|
15
|
+
`for:`/`value:`, `at_least:`/`given:`, and `exact:`/`given:`); arity
|
|
16
|
+
expectations are an Integer or Range (`expected: 1..` means "at least one")
|
|
17
|
+
and `actual:` always carries the offending value itself, not its class
|
|
18
|
+
- `meta[:operation]` is now always the AST class and `meta[:operator]` the
|
|
19
|
+
method symbol (previously `ParseError` used `:operator` for the class);
|
|
20
|
+
`NodeError#child` is renamed to `#operand` (it holds the failing operand's
|
|
21
|
+
position, `:left` / `:right` / `:node`) and `NodeError#expect` to `#expected`;
|
|
22
|
+
`function_name:` metadata carries bare names (`'AND'`, not `'AND()'`)
|
|
23
|
+
- `Dentaku::Error#recipient_variable` is renamed to `#assigned_to`
|
|
24
|
+
- bitwise operations on non-integer operands raise `:incompatible_type`
|
|
25
|
+
(previously `:invalid_operator`) with a descriptive message instead of a
|
|
26
|
+
bare "Dentaku::ArgumentError"
|
|
27
|
+
- indexing into a non-indexable value (e.g. `a[0]` where `a` is a number,
|
|
28
|
+
`NULL`, or a boolean) now raises `Dentaku::ArgumentError` instead of
|
|
29
|
+
silently returning a bit-reference result or leaking a raw Ruby
|
|
30
|
+
`NoMethodError`; an incompatible index type (e.g. a string index into an
|
|
31
|
+
array) raises `Dentaku::ArgumentError` instead of a raw `TypeError`
|
|
32
|
+
- `AND` / `OR` short-circuit: an unbound variable no longer raises when the
|
|
33
|
+
bound operand already decides the result (#234); formulas are documented
|
|
34
|
+
as pure, and misspelled identifiers on never-taken branches are no longer
|
|
35
|
+
reported at evaluation time (use `dependencies` for static validation)
|
|
36
|
+
- `TokenMatcher` no longer builds function matchers via `method_missing`; use
|
|
37
|
+
`TokenMatcher.function(name)` (the unused `math_neg_pow` / `math_neg_mul`
|
|
38
|
+
matcher symbols were removed)
|
|
39
|
+
|
|
40
|
+
OTHER CHANGES
|
|
41
|
+
- add `volatile:` option to `add_function` / `add_functions` / function
|
|
42
|
+
registration for functions that read external state or return different
|
|
43
|
+
values across calls; volatile functions are never executed during
|
|
44
|
+
dependency analysis, so a conditional guarded by one reports all branches
|
|
45
|
+
as dependencies (and `evaluate!` requires variables from every branch),
|
|
46
|
+
and the guard is evaluated exactly once per `evaluate!` instead of twice
|
|
47
|
+
(#339, thanks @d-krushinsky)
|
|
48
|
+
- add `Calculator#identifiers`, a purely syntactic listing of every
|
|
49
|
+
identifier a formula could reference regardless of branching -- nothing
|
|
50
|
+
is evaluated and stored variables are not subtracted (#197, #339)
|
|
51
|
+
- cache flags can be set per calculator (`cache_ast:`,
|
|
52
|
+
`cache_dependency_order:`), defaulting to the module-level settings
|
|
53
|
+
- module-level `Dentaku.aliases` is resolved lazily, so aliases set after a
|
|
54
|
+
calculator was created (including the implicit top-level calculator) apply
|
|
55
|
+
- `CASE` expressions only report dependencies for the branch that would be
|
|
56
|
+
taken when the switch value is resolvable
|
|
57
|
+
- operand type-mismatch parse errors now read as natural English: the parser
|
|
58
|
+
preserves the node-level message ("Dentaku::AST::Addition requires operands
|
|
59
|
+
that are numeric or compatible types, not string") instead of rebuilding a
|
|
60
|
+
misleading one ("requires incompatible operands, but got string"); the
|
|
61
|
+
metadata-derived fallback message says "compatible" rather than
|
|
62
|
+
"incompatible" (idea from #341, thanks @moskvin)
|
|
63
|
+
- `Dentaku::ArgumentError`, `ParseError`, and `TokenizerError` build their own
|
|
64
|
+
default messages from `reason` and `meta` (existing message text is
|
|
65
|
+
unchanged); `Parser#fail!` and `Tokenizer#fail!` reduce to one-liners
|
|
66
|
+
- fix parsing of `CASE` statements with an unparenthesized operation as the
|
|
67
|
+
switch expression (`CASE a % 5 WHEN ...`), which also makes `PrintVisitor`
|
|
68
|
+
output for such statements re-parseable
|
|
69
|
+
- fix `recipient_variable` being nil inside `solve` blocks, a regression
|
|
70
|
+
introduced in 3.5.4 (#333)
|
|
71
|
+
- fix crashes on two error paths: the parser's unbalanced-parenthesis report
|
|
72
|
+
raised a Ruby `ArgumentError` instead of a `ParseError`, and the tokenizer's
|
|
73
|
+
zero-width-match report raised a `KeyError` instead of a `TokenizerError`
|
|
74
|
+
- document Ruby compatibility policy
|
|
75
|
+
- modernize low-risk Ruby syntax
|
|
76
|
+
- remove Travis CI configuration
|
|
4
77
|
|
|
5
78
|
## [v3.5.8] 2026-08-01
|
|
6
79
|
- unify numeric matching and parsing
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -13,6 +13,16 @@ language that allows run-time binding of values to variables referenced in the
|
|
|
13
13
|
formulas. It is intended to safely evaluate untrusted expressions without
|
|
14
14
|
opening security holes.
|
|
15
15
|
|
|
16
|
+
COMPATIBILITY
|
|
17
|
+
-------------
|
|
18
|
+
|
|
19
|
+
Dentaku supports Ruby 3.2 and newer, tracking Ruby's own maintenance policy:
|
|
20
|
+
when a Ruby version reaches end-of-life, it may be dropped from the supported
|
|
21
|
+
compatibility contract in the next major or minor release. Older Rubies may
|
|
22
|
+
continue to work, but are not tested.
|
|
23
|
+
|
|
24
|
+
New code should target the lowest supported Ruby version, currently Ruby 3.2.
|
|
25
|
+
|
|
16
26
|
EXAMPLE
|
|
17
27
|
-------
|
|
18
28
|
|
|
@@ -124,6 +134,13 @@ phases, so if performance is a concern, you can enable AST caching:
|
|
|
124
134
|
Dentaku.enable_ast_cache!
|
|
125
135
|
```
|
|
126
136
|
|
|
137
|
+
Caching can also be controlled per calculator, overriding the module-level
|
|
138
|
+
setting:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
calculator = Dentaku::Calculator.new(cache_ast: true)
|
|
142
|
+
```
|
|
143
|
+
|
|
127
144
|
After this, Dentaku will cache the AST of each formula that it evaluates, so
|
|
128
145
|
subsequent evaluations (even with different values for variables) will be much
|
|
129
146
|
faster -- closer to 4x native Ruby speed. As usual, these benchmarks should be
|
|
@@ -131,6 +148,57 @@ considered rough estimates, and you should measure with representative formulas
|
|
|
131
148
|
from your application. Also, if new formulas are constantly introduced to your
|
|
132
149
|
application, AST caching will consume more memory with each new formula.
|
|
133
150
|
|
|
151
|
+
DEPENDENCY ANALYSIS AND SHORT-CIRCUITING
|
|
152
|
+
----------------------------------------
|
|
153
|
+
|
|
154
|
+
Dentaku treats formulas as pure: a subexpression may be evaluated zero, one,
|
|
155
|
+
or more than one time (for example, a guard position may be evaluated once
|
|
156
|
+
during dependency analysis and again during evaluation), so custom functions
|
|
157
|
+
should not rely on side effects or call counts. Functions that fall outside
|
|
158
|
+
this contract -- reading external state, performing I/O, or returning
|
|
159
|
+
different values across calls -- must be registered with `volatile: true`
|
|
160
|
+
(see EXTERNAL FUNCTIONS below). Volatile functions are never invoked during
|
|
161
|
+
dependency analysis; in guard positions this means no branch can be pruned,
|
|
162
|
+
so all branches count as dependencies and `evaluate!` requires variables
|
|
163
|
+
from every branch to be bound.
|
|
164
|
+
|
|
165
|
+
Logical constructs short-circuit. `IF` and `CASE` only evaluate the branch
|
|
166
|
+
that is taken, and `AND` / `OR` resolve as soon as one operand decides the
|
|
167
|
+
result. An unbound variable raises `Dentaku::UnboundVariableError` only when
|
|
168
|
+
its value is actually needed:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
calculator.evaluate!('a OR b', a: true) #=> true
|
|
172
|
+
calculator.evaluate!('a OR b', a: false) #=> raises Dentaku::UnboundVariableError
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Two methods answer two different questions about a formula's inputs:
|
|
176
|
+
|
|
177
|
+
`calculator.dependencies(expression, context)` reports the identifiers a
|
|
178
|
+
formula still needs, given what is already known. It is resolution-aware:
|
|
179
|
+
guard positions (`IF` predicates, `CASE` switches, `AND` / `OR` operands)
|
|
180
|
+
whose own dependencies are already satisfied are evaluated -- executing any
|
|
181
|
+
(non-volatile) functions they contain -- in order to prune branches that
|
|
182
|
+
cannot be taken.
|
|
183
|
+
|
|
184
|
+
`calculator.identifiers(expression)` reports every identifier the formula
|
|
185
|
+
could ever reference, regardless of branching. It is purely syntactic:
|
|
186
|
+
nothing is evaluated, no functions run (volatile or not), and stored
|
|
187
|
+
variables are not subtracted. Use it to statically list a formula's
|
|
188
|
+
possible inputs, e.g. to tell users what values they may need to supply.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
calculator.dependencies('IF(x > 5, y, z)', x: 7) #=> ["y"]
|
|
192
|
+
calculator.identifiers('IF(x > 5, y, z)') #=> ["x", "y", "z"]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Because unused operands are never checked at evaluation time, a misspelled
|
|
196
|
+
identifier on a branch that is not taken will not be reported; use
|
|
197
|
+
`identifiers` for static validation.
|
|
198
|
+
|
|
199
|
+
Context keys prefixed with a double underscore (e.g. `__evaluation_mode`)
|
|
200
|
+
are reserved for internal use.
|
|
201
|
+
|
|
134
202
|
BUILT-IN OPERATORS AND FUNCTIONS
|
|
135
203
|
---------------------------------
|
|
136
204
|
|
|
@@ -279,6 +347,25 @@ function)
|
|
|
279
347
|
Functions can be added individually using Calculator#add_function, or en masse
|
|
280
348
|
using Calculator#add_functions.
|
|
281
349
|
|
|
350
|
+
Dentaku assumes registered functions are pure (see DEPENDENCY ANALYSIS AND
|
|
351
|
+
SHORT-CIRCUITING above). A function that reads external state, performs
|
|
352
|
+
I/O, or returns different values across calls must be declared volatile so
|
|
353
|
+
that dependency analysis never executes it:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
> c = Dentaku::Calculator.new
|
|
357
|
+
> c.add_function(:user_level, :numeric, -> { Current.user.level }, volatile: true)
|
|
358
|
+
> c.identifiers('IF(user_level() > 50, high_rate, low_rate)')
|
|
359
|
+
#=> ["high_rate", "low_rate"]
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
A volatile function in a guard position prevents branch pruning: both
|
|
363
|
+
branches count as dependencies, and `evaluate!` requires variables from
|
|
364
|
+
every branch (Dentaku cannot know in advance which branch will be taken).
|
|
365
|
+
The predicate itself is then evaluated exactly once per `evaluate!` call,
|
|
366
|
+
at evaluation time. In `add_functions`, pass volatility as a fifth tuple
|
|
367
|
+
element: `[:user_level, :numeric, -> { ... }, nil, true]`.
|
|
368
|
+
|
|
282
369
|
FUNCTION ALIASES
|
|
283
370
|
----------------
|
|
284
371
|
|
data/dentaku.gemspec
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
$:.push File.expand_path("../lib", __FILE__)
|
|
3
|
-
require "dentaku/version"
|
|
1
|
+
require_relative "lib/dentaku/version"
|
|
4
2
|
|
|
5
3
|
Gem::Specification.new do |s|
|
|
6
4
|
s.name = "dentaku"
|
|
7
5
|
s.version = Dentaku::VERSION
|
|
8
6
|
s.authors = ["Solomon White"]
|
|
9
7
|
s.email = ["rubysolo@gmail.com"]
|
|
10
|
-
s.homepage = "
|
|
8
|
+
s.homepage = "https://github.com/rubysolo/dentaku"
|
|
11
9
|
s.licenses = %w(MIT)
|
|
12
10
|
s.summary = 'A formula language parser and evaluator'
|
|
13
11
|
s.description = <<-DESC
|
|
14
12
|
Dentaku is a parser and evaluator for mathematical formulas
|
|
15
13
|
DESC
|
|
16
14
|
|
|
17
|
-
s.
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
s.metadata = {
|
|
16
|
+
"homepage_uri" => s.homepage,
|
|
17
|
+
"source_code_uri" => s.homepage,
|
|
18
|
+
"changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
|
|
19
|
+
"bug_tracker_uri" => "#{s.homepage}/issues",
|
|
20
|
+
"rubygems_mfa_required" => "true",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
s.add_dependency('bigdecimal', '>= 3.1')
|
|
24
|
+
s.add_dependency('concurrent-ruby', '~> 1.1')
|
|
25
|
+
s.add_dependency('tsort', '>= 0.1.1')
|
|
26
|
+
|
|
27
|
+
s.required_ruby_version = ">= 3.2"
|
|
20
28
|
|
|
21
29
|
s.files = `git ls-files`.split("\n")
|
|
22
|
-
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
23
30
|
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
|
24
31
|
s.require_paths = ["lib"]
|
|
25
32
|
end
|
data/lib/dentaku/ast/access.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require_relative "../exceptions"
|
|
1
2
|
require_relative "./node"
|
|
2
3
|
|
|
3
4
|
module Dentaku
|
|
@@ -25,7 +26,17 @@ module Dentaku
|
|
|
25
26
|
def value(context = {})
|
|
26
27
|
structure = @structure.value(context)
|
|
27
28
|
index = @index.value(context)
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
unless structure.respond_to?(:[]) && !structure.is_a?(::Numeric)
|
|
31
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: structure),
|
|
32
|
+
"#{self.class} requires an indexable structure, but got #{structure.class}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
structure[index]
|
|
37
|
+
rescue ::TypeError => e
|
|
38
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: index), e.message
|
|
39
|
+
end
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def dependencies(context = {})
|
|
@@ -39,6 +50,12 @@ module Dentaku
|
|
|
39
50
|
def accept(visitor)
|
|
40
51
|
visitor.visit_access(self)
|
|
41
52
|
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def compute_pure?
|
|
57
|
+
@structure.pure? && @index.pure?
|
|
58
|
+
end
|
|
42
59
|
end
|
|
43
60
|
end
|
|
44
61
|
end
|
|
@@ -41,7 +41,7 @@ module Dentaku
|
|
|
41
41
|
l.public_send(operator, r)
|
|
42
42
|
rescue ::TypeError => e
|
|
43
43
|
# Right cannot be converted to a suitable type for left. e.g. [] + 1
|
|
44
|
-
raise Dentaku::ArgumentError.for(:incompatible_type,
|
|
44
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def cast(val)
|
|
@@ -97,14 +97,13 @@ module Dentaku
|
|
|
97
97
|
|
|
98
98
|
def validate_operation(val)
|
|
99
99
|
unless val.respond_to?(operator)
|
|
100
|
-
raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
|
|
101
|
-
"#{ self.class } requires operands that respond to #{operator}"
|
|
100
|
+
raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
|
|
102
101
|
end
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
def validate_format(string)
|
|
106
105
|
unless Dentaku::NumericParser.match(string)
|
|
107
|
-
raise Dentaku::ArgumentError.for(:invalid_value,
|
|
106
|
+
raise Dentaku::ArgumentError.for(:invalid_value, actual: string, expected: BigDecimal),
|
|
108
107
|
"String input '#{string}' is not coercible to numeric"
|
|
109
108
|
end
|
|
110
109
|
end
|
data/lib/dentaku/ast/array.rb
CHANGED
data/lib/dentaku/ast/bitwise.rb
CHANGED
|
@@ -8,10 +8,12 @@ module Dentaku
|
|
|
8
8
|
right_value = right.value(context)
|
|
9
9
|
|
|
10
10
|
left_value.public_send(operator, right_value)
|
|
11
|
-
rescue NoMethodError
|
|
12
|
-
raise Dentaku::ArgumentError.for(:
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
rescue NoMethodError
|
|
12
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: left_value, expected: Integer),
|
|
13
|
+
"#{self.class} requires integer operands, but got #{left_value.class}"
|
|
14
|
+
rescue TypeError
|
|
15
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: right_value, expected: Integer),
|
|
16
|
+
"#{self.class} requires integer operands, but got #{right_value.class}"
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
|
data/lib/dentaku/ast/case.rb
CHANGED
|
@@ -65,10 +65,19 @@ module Dentaku
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def dependencies(context = {})
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
return all_dependencies(context) if static_mode?(context) || !prunable?
|
|
69
|
+
|
|
70
|
+
switch_value = @switch.value(context)
|
|
71
|
+
|
|
72
|
+
@conditions.each do |condition|
|
|
73
|
+
if condition.when.value(context) == switch_value
|
|
74
|
+
return condition.then.dependencies(context)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
71
78
|
else_dependencies(context)
|
|
79
|
+
rescue Dentaku::Error
|
|
80
|
+
all_dependencies(context)
|
|
72
81
|
end
|
|
73
82
|
|
|
74
83
|
def accept(visitor)
|
|
@@ -77,6 +86,20 @@ module Dentaku
|
|
|
77
86
|
|
|
78
87
|
private
|
|
79
88
|
|
|
89
|
+
# pruning evaluates the switch and each when-clause until one matches;
|
|
90
|
+
# all of them must be side-effect free before we may do that
|
|
91
|
+
def prunable?
|
|
92
|
+
return @prunable if defined?(@prunable)
|
|
93
|
+
|
|
94
|
+
@prunable = @switch.pure? && @conditions.all? { |condition| condition.when.pure? }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def all_dependencies(context)
|
|
98
|
+
switch_dependencies(context) +
|
|
99
|
+
condition_dependencies(context) +
|
|
100
|
+
else_dependencies(context)
|
|
101
|
+
end
|
|
102
|
+
|
|
80
103
|
def switch_dependencies(context = {})
|
|
81
104
|
@switch.dependencies(context)
|
|
82
105
|
end
|
|
@@ -88,6 +111,10 @@ module Dentaku
|
|
|
88
111
|
def else_dependencies(context = {})
|
|
89
112
|
@else ? @else.dependencies(context) : []
|
|
90
113
|
end
|
|
114
|
+
|
|
115
|
+
def compute_pure?
|
|
116
|
+
([@switch] + @conditions + [@else]).compact.all?(&:pure?)
|
|
117
|
+
end
|
|
91
118
|
end
|
|
92
119
|
end
|
|
93
120
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative './operation'
|
|
2
|
+
require 'dentaku/exceptions'
|
|
2
3
|
|
|
3
4
|
module Dentaku
|
|
4
5
|
module AST
|
|
@@ -20,11 +21,51 @@ module Dentaku
|
|
|
20
21
|
:logical
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
def dependencies(context = {})
|
|
25
|
+
return super if static_mode?(context)
|
|
26
|
+
|
|
27
|
+
left_deps = left.dependencies(context)
|
|
28
|
+
right_deps = right.dependencies(context)
|
|
29
|
+
return [] if left_deps.empty? && left.pure? && decisive?(left.value(context))
|
|
30
|
+
return [] if right_deps.empty? && right.pure? && decisive?(right.value(context))
|
|
31
|
+
|
|
32
|
+
(left_deps + right_deps).uniq
|
|
33
|
+
rescue Dentaku::Error
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def value(context = {})
|
|
38
|
+
left_value = begin
|
|
39
|
+
left.value(context)
|
|
40
|
+
rescue UnboundVariableError => unbound
|
|
41
|
+
unbound
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless left_value.is_a?(UnboundVariableError)
|
|
45
|
+
return left_value if decisive?(left_value)
|
|
46
|
+
|
|
47
|
+
return right.value(context)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The left operand is unbound; the right operand can still decide the
|
|
51
|
+
# result on its own. If it does not, the left value was needed.
|
|
52
|
+
right_value = right.value(context)
|
|
53
|
+
raise left_value unless decisive?(right_value)
|
|
54
|
+
|
|
55
|
+
right_value
|
|
56
|
+
end
|
|
57
|
+
|
|
23
58
|
private
|
|
24
59
|
|
|
25
60
|
def valid_node?(node)
|
|
26
61
|
node && (node.dependencies.any? || node.type == :logical)
|
|
27
62
|
end
|
|
63
|
+
|
|
64
|
+
# whether a single operand with this value already determines the
|
|
65
|
+
# result, regardless of the other operand
|
|
66
|
+
def decisive?(operand_value)
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
end
|
|
28
69
|
end
|
|
29
70
|
|
|
30
71
|
class And < Combinator
|
|
@@ -32,8 +73,10 @@ module Dentaku
|
|
|
32
73
|
:and
|
|
33
74
|
end
|
|
34
75
|
|
|
35
|
-
|
|
36
|
-
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def decisive?(operand_value)
|
|
79
|
+
!operand_value
|
|
37
80
|
end
|
|
38
81
|
end
|
|
39
82
|
|
|
@@ -42,8 +85,10 @@ module Dentaku
|
|
|
42
85
|
:or
|
|
43
86
|
end
|
|
44
87
|
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def decisive?(operand_value)
|
|
91
|
+
!!operand_value
|
|
47
92
|
end
|
|
48
93
|
end
|
|
49
94
|
end
|
|
@@ -21,7 +21,7 @@ module Dentaku
|
|
|
21
21
|
|
|
22
22
|
l.public_send(operator, r)
|
|
23
23
|
rescue ::ArgumentError, ::TypeError => e
|
|
24
|
-
raise Dentaku::ArgumentError.for(:incompatible_type,
|
|
24
|
+
raise Dentaku::ArgumentError.for(:incompatible_type, actual: r, expected: l.class), e.message
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
private
|
|
@@ -34,8 +34,7 @@ module Dentaku
|
|
|
34
34
|
|
|
35
35
|
def validate_value(value)
|
|
36
36
|
unless value.respond_to?(operator)
|
|
37
|
-
raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
|
|
38
|
-
"#{ self.class } requires operands that respond to #{operator}"
|
|
37
|
+
raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
|
|
39
38
|
end
|
|
40
39
|
|
|
41
40
|
value
|
data/lib/dentaku/ast/function.rb
CHANGED
|
@@ -22,12 +22,19 @@ module Dentaku
|
|
|
22
22
|
.flat_map { |a, _| a.dependencies(context) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# volatile functions may not be evaluated during dependency
|
|
26
|
+
# resolution; registry-generated classes override this per the
|
|
27
|
+
# volatile: option passed at registration
|
|
28
|
+
def self.volatile?
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
def self.get(name)
|
|
26
33
|
registry.get(name)
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
def self.register(name, type, implementation)
|
|
30
|
-
registry.register(name, type, implementation)
|
|
36
|
+
def self.register(name, type, implementation, volatile: false)
|
|
37
|
+
registry.register(name, type, implementation, volatile: volatile)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def self.register_class(name, function_class)
|
|
@@ -37,6 +44,12 @@ module Dentaku
|
|
|
37
44
|
def self.registry
|
|
38
45
|
@registry ||= FunctionRegistry.new
|
|
39
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def compute_pure?
|
|
51
|
+
!self.class.volatile? && args.all?(&:pure?)
|
|
52
|
+
end
|
|
40
53
|
end
|
|
41
54
|
end
|
|
42
55
|
end
|