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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +2 -4
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/CHANGELOG.md +74 -1
  6. data/Gemfile +1 -1
  7. data/README.md +87 -0
  8. data/dentaku.gemspec +15 -8
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +3 -4
  11. data/lib/dentaku/ast/array.rb +6 -0
  12. data/lib/dentaku/ast/bitwise.rb +6 -4
  13. data/lib/dentaku/ast/case/case_conditional.rb +6 -0
  14. data/lib/dentaku/ast/case/case_else.rb +6 -0
  15. data/lib/dentaku/ast/case/case_switch_variable.rb +6 -0
  16. data/lib/dentaku/ast/case/case_then.rb +6 -0
  17. data/lib/dentaku/ast/case/case_when.rb +7 -0
  18. data/lib/dentaku/ast/case.rb +30 -3
  19. data/lib/dentaku/ast/combinators.rb +49 -4
  20. data/lib/dentaku/ast/comparators.rb +2 -3
  21. data/lib/dentaku/ast/function.rb +15 -2
  22. data/lib/dentaku/ast/function_registry.rb +10 -1
  23. data/lib/dentaku/ast/functions/and.rb +4 -4
  24. data/lib/dentaku/ast/functions/avg.rb +2 -2
  25. data/lib/dentaku/ast/functions/duration.rb +1 -1
  26. data/lib/dentaku/ast/functions/if.rb +9 -1
  27. data/lib/dentaku/ast/functions/intercept.rb +2 -2
  28. data/lib/dentaku/ast/functions/mul.rb +2 -2
  29. data/lib/dentaku/ast/functions/or.rb +4 -4
  30. data/lib/dentaku/ast/functions/pluck.rb +1 -1
  31. data/lib/dentaku/ast/functions/string_functions.rb +1 -1
  32. data/lib/dentaku/ast/functions/sum.rb +2 -2
  33. data/lib/dentaku/ast/functions/xor.rb +4 -4
  34. data/lib/dentaku/ast/grouping.rb +6 -0
  35. data/lib/dentaku/ast/negation.rb +5 -0
  36. data/lib/dentaku/ast/node.rb +25 -0
  37. data/lib/dentaku/ast/operation.rb +7 -0
  38. data/lib/dentaku/bulk_expression_solver.rb +9 -10
  39. data/lib/dentaku/calculator.rb +45 -17
  40. data/lib/dentaku/date_arithmetic.rb +2 -2
  41. data/lib/dentaku/exceptions.rb +105 -13
  42. data/lib/dentaku/numeric_parser.rb +1 -1
  43. data/lib/dentaku/parser.rb +24 -48
  44. data/lib/dentaku/token_matcher.rb +31 -37
  45. data/lib/dentaku/token_matchers.rb +3 -3
  46. data/lib/dentaku/tokenizer.rb +1 -15
  47. data/lib/dentaku/version.rb +1 -1
  48. data/spec/ast/case_spec.rb +7 -0
  49. data/spec/bulk_expression_solver_spec.rb +13 -3
  50. data/spec/calculator_spec.rb +67 -0
  51. data/spec/exceptions_spec.rb +87 -0
  52. data/spec/gemspec_spec.rb +10 -0
  53. data/spec/identifiers_spec.rb +66 -0
  54. data/spec/parser_spec.rb +35 -0
  55. data/spec/print_visitor_spec.rb +5 -0
  56. data/spec/volatile_functions_spec.rb +121 -0
  57. metadata +21 -63
  58. data/.travis.yml +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d84ad3261b937673547d328aae718b0a7389bca75b6aedaaa8b82dff2c1f9f9
4
- data.tar.gz: 5f6e809c2a510e2d32a07ed0838ad878dd46bb9e475e73626dced73f219280c8
3
+ metadata.gz: a911d2d58c12144c0692b27a9abe5ad08716e1800deb9e9bda08bee04735b9ac
4
+ data.tar.gz: 8e55f39625ebb6a6a5f4d38b820137566c9bd2d529d411cb4c40c89770dbf048
5
5
  SHA512:
6
- metadata.gz: e939d79f7b4defcc2596b2a83fe612fbebf298cdb2b3a38b0cd7978ef8bdabeab6321e2b381ad9de83d28cd10bd807eabd4af1c8d095446ffca3c7515cbfe015
7
- data.tar.gz: 390db232aa4a54ca779e0ddfe762cfbf9b5279c60731809ca6a5d59013ef3f3d161c298dd315daae4beeb7c5a51f4285e29b5f48d241f77d33d63e7787a2f91b
6
+ metadata.gz: ac372c840c68d33bb1a6577640ea17812e0aab7b1f82c1979d1caac5ef670148743ecc77f87c8cf32a33e5e90d6f9f35f5d6da85da3e345beb64cf0a0d7d2b78
7
+ data.tar.gz: 4d9d05f5b7a79751cfc4f82576b6bddd5d621e02df4ee98282052d55cbe87c250d5f0906960726278ee078d9162f369a9efd42f166d7446d189aeb80de7c166d
@@ -8,12 +8,10 @@ jobs:
8
8
  strategy:
9
9
  matrix:
10
10
  ruby:
11
- - '3.0'
12
- - '3.1'
13
- - '3.2'
11
+ - '3.2' # minimum supported Ruby
14
12
  - '3.3'
15
13
  - '3.4'
16
- - '4.0'
14
+ - '4.0' # future compatibility signal
17
15
  fail-fast: false
18
16
  steps:
19
17
  - uses: actions/checkout@v4
@@ -9,6 +9,6 @@ jobs:
9
9
  - uses: actions/checkout@v4
10
10
  - uses: ruby/setup-ruby@v1
11
11
  with:
12
- ruby-version: '3'
12
+ ruby-version: '3.2'
13
13
  bundler-cache: true
14
14
  - run: bundle exec rubocop
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
2
+ TargetRubyVersion: 3.2
3
3
  # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop
4
4
  # to ignore them, so only the ones explicitly set in this file are enabled.
5
5
  DisabledByDefault: true
data/CHANGELOG.md CHANGED
@@ -1,6 +1,79 @@
1
1
  # Change Log
2
2
 
3
- ## [UNRELEASED]
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
@@ -1,4 +1,4 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  # Specify your gem's dependencies in dentaku.gemspec
4
4
  gemspec
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
- # -*- encoding: utf-8 -*-
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 = "http://github.com/rubysolo/dentaku"
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.add_dependency('bigdecimal')
18
- s.add_dependency('concurrent-ruby')
19
- s.add_dependency('tsort')
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
@@ -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
- structure[index]
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, value: r, for: l.class), e.message
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, value: string, for: BigDecimal),
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
@@ -33,6 +33,12 @@ module Dentaku
33
33
  def accept(visitor)
34
34
  visitor.visit_array(self)
35
35
  end
36
+
37
+ private
38
+
39
+ def compute_pure?
40
+ @elements.all?(&:pure?)
41
+ end
36
42
  end
37
43
  end
38
44
  end
@@ -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 => e
12
- raise Dentaku::ArgumentError.for(:invalid_operator, value: left_value, for: left_value.class)
13
- rescue TypeError => e
14
- raise Dentaku::ArgumentError.for(:invalid_operator, value: right_value, for: right_value.class)
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
 
@@ -33,6 +33,12 @@ module Dentaku
33
33
  def accept(visitor)
34
34
  visitor.visit_case_conditional(self)
35
35
  end
36
+
37
+ private
38
+
39
+ def compute_pure?
40
+ @when.pure? && @then.pure?
41
+ end
36
42
  end
37
43
  end
38
44
  end
@@ -30,6 +30,12 @@ module Dentaku
30
30
  def accept(visitor)
31
31
  visitor.visit_else(self)
32
32
  end
33
+
34
+ private
35
+
36
+ def compute_pure?
37
+ @node.pure?
38
+ end
33
39
  end
34
40
  end
35
41
  end
@@ -30,6 +30,12 @@ module Dentaku
30
30
  def accept(visitor)
31
31
  visitor.visit_switch(self)
32
32
  end
33
+
34
+ private
35
+
36
+ def compute_pure?
37
+ @node.pure?
38
+ end
33
39
  end
34
40
  end
35
41
  end
@@ -30,6 +30,12 @@ module Dentaku
30
30
  def accept(visitor)
31
31
  visitor.visit_then(self)
32
32
  end
33
+
34
+ private
35
+
36
+ def compute_pure?
37
+ @node.pure?
38
+ end
33
39
  end
34
40
  end
35
41
  end
@@ -34,6 +34,13 @@ module Dentaku
34
34
  def to_s
35
35
  'WHEN'
36
36
  end
37
+
38
+ private
39
+
40
+ # bypasses Operation's binary initialize, so left/right are unset
41
+ def compute_pure?
42
+ @node.pure?
43
+ end
37
44
  end
38
45
  end
39
46
  end
@@ -65,10 +65,19 @@ module Dentaku
65
65
  end
66
66
 
67
67
  def dependencies(context = {})
68
- # TODO: should short-circuit
69
- switch_dependencies(context) +
70
- condition_dependencies(context) +
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
- def value(context = {})
36
- left.value(context) && right.value(context)
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
- def value(context = {})
46
- left.value(context) || right.value(context)
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, value: r, for: l.class), e.message
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
@@ -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