dentaku 3.5.8 → 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.
Files changed (61) 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 +108 -1
  6. data/Gemfile +1 -1
  7. data/README.md +123 -0
  8. data/dentaku.gemspec +15 -8
  9. data/lib/dentaku/ast/access.rb +18 -1
  10. data/lib/dentaku/ast/arithmetic.rb +22 -9
  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 +52 -5
  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 +28 -2
  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 +8 -1
  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 +83 -34
  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/arithmetic_spec.rb +93 -0
  49. data/spec/ast/case_spec.rb +7 -0
  50. data/spec/bulk_expression_solver_spec.rb +13 -3
  51. data/spec/calculator_spec.rb +146 -0
  52. data/spec/exceptions_spec.rb +87 -0
  53. data/spec/external_function_spec.rb +33 -0
  54. data/spec/gemspec_spec.rb +10 -0
  55. data/spec/identifiers_spec.rb +66 -0
  56. data/spec/parser_spec.rb +35 -0
  57. data/spec/print_visitor_spec.rb +5 -0
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/volatile_functions_spec.rb +121 -0
  60. metadata +21 -63
  61. 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: 8c52e5ed43e8cc6d9ac6f3cf81ae522e408860ab24bf59109bdd97301e0d6e12
4
+ data.tar.gz: fa376e35d073d5c0ebce858f96c4c1322ed1bba2a202dc6e41d265c1e5fa24f3
5
5
  SHA512:
6
- metadata.gz: e939d79f7b4defcc2596b2a83fe612fbebf298cdb2b3a38b0cd7978ef8bdabeab6321e2b381ad9de83d28cd10bd807eabd4af1c8d095446ffca3c7515cbfe015
7
- data.tar.gz: 390db232aa4a54ca779e0ddfe762cfbf9b5279c60731809ca6a5d59013ef3f3d161c298dd315daae4beeb7c5a51f4285e29b5f48d241f77d33d63e7787a2f91b
6
+ metadata.gz: 040e836d96fad9833095285a2d3a9b6b7e7a3c4bffcd84cac9603bc16128308acb719103eb96693cc04910c028265231f72f982658dd34523d3685ac4dea8e7d
7
+ data.tar.gz: 66aa9ee21ce77b2d9dd25d44b3a7f1db7212e7776614839d4c92e403ed0d4e5561fee07e28a50eb81c1a886bf889c1ae285c4a89aa78a59910f5636de66159d4
@@ -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,113 @@
1
1
  # Change Log
2
2
 
3
- ## [UNRELEASED]
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
+
37
+ ## [v4.0.0] 2026-08-01
38
+ BREAKING CHANGES
39
+ - require Ruby 3.2 or newer
40
+ - `Calculator.new` takes keyword arguments; the AST cache must now be passed
41
+ explicitly via `ast_cache:` instead of as leftover options, unknown options
42
+ raise `ArgumentError`, and the passed values are no longer mutated
43
+ - `Dentaku::Error` is now a module included by all Dentaku exceptions, so
44
+ `rescue Dentaku::Error` also catches `Dentaku::ArgumentError` and
45
+ `Dentaku::ZeroDivisionError`; exceptions that previously subclassed
46
+ `Dentaku::Error` now subclass `Dentaku::BaseError`
47
+ - unify exception metadata naming: the "required vs. received" pair is now
48
+ `expected:` / `actual:` everywhere (previously a mix of `expect:`/`actual:`,
49
+ `for:`/`value:`, `at_least:`/`given:`, and `exact:`/`given:`); arity
50
+ expectations are an Integer or Range (`expected: 1..` means "at least one")
51
+ and `actual:` always carries the offending value itself, not its class
52
+ - `meta[:operation]` is now always the AST class and `meta[:operator]` the
53
+ method symbol (previously `ParseError` used `:operator` for the class);
54
+ `NodeError#child` is renamed to `#operand` (it holds the failing operand's
55
+ position, `:left` / `:right` / `:node`) and `NodeError#expect` to `#expected`;
56
+ `function_name:` metadata carries bare names (`'AND'`, not `'AND()'`)
57
+ - `Dentaku::Error#recipient_variable` is renamed to `#assigned_to`
58
+ - bitwise operations on non-integer operands raise `:incompatible_type`
59
+ (previously `:invalid_operator`) with a descriptive message instead of a
60
+ bare "Dentaku::ArgumentError"
61
+ - indexing into a non-indexable value (e.g. `a[0]` where `a` is a number,
62
+ `NULL`, or a boolean) now raises `Dentaku::ArgumentError` instead of
63
+ silently returning a bit-reference result or leaking a raw Ruby
64
+ `NoMethodError`; an incompatible index type (e.g. a string index into an
65
+ array) raises `Dentaku::ArgumentError` instead of a raw `TypeError`
66
+ - `AND` / `OR` short-circuit: an unbound variable no longer raises when the
67
+ bound operand already decides the result (#234); formulas are documented
68
+ as pure, and misspelled identifiers on never-taken branches are no longer
69
+ reported at evaluation time (use `dependencies` for static validation)
70
+ - `TokenMatcher` no longer builds function matchers via `method_missing`; use
71
+ `TokenMatcher.function(name)` (the unused `math_neg_pow` / `math_neg_mul`
72
+ matcher symbols were removed)
73
+
74
+ OTHER CHANGES
75
+ - add `volatile:` option to `add_function` / `add_functions` / function
76
+ registration for functions that read external state or return different
77
+ values across calls; volatile functions are never executed during
78
+ dependency analysis, so a conditional guarded by one reports all branches
79
+ as dependencies (and `evaluate!` requires variables from every branch),
80
+ and the guard is evaluated exactly once per `evaluate!` instead of twice
81
+ (#339, thanks @d-krushinsky)
82
+ - add `Calculator#identifiers`, a purely syntactic listing of every
83
+ identifier a formula could reference regardless of branching -- nothing
84
+ is evaluated and stored variables are not subtracted (#197, #339)
85
+ - cache flags can be set per calculator (`cache_ast:`,
86
+ `cache_dependency_order:`), defaulting to the module-level settings
87
+ - module-level `Dentaku.aliases` is resolved lazily, so aliases set after a
88
+ calculator was created (including the implicit top-level calculator) apply
89
+ - `CASE` expressions only report dependencies for the branch that would be
90
+ taken when the switch value is resolvable
91
+ - operand type-mismatch parse errors now read as natural English: the parser
92
+ preserves the node-level message ("Dentaku::AST::Addition requires operands
93
+ that are numeric or compatible types, not string") instead of rebuilding a
94
+ misleading one ("requires incompatible operands, but got string"); the
95
+ metadata-derived fallback message says "compatible" rather than
96
+ "incompatible" (idea from #341, thanks @moskvin)
97
+ - `Dentaku::ArgumentError`, `ParseError`, and `TokenizerError` build their own
98
+ default messages from `reason` and `meta` (existing message text is
99
+ unchanged); `Parser#fail!` and `Tokenizer#fail!` reduce to one-liners
100
+ - fix parsing of `CASE` statements with an unparenthesized operation as the
101
+ switch expression (`CASE a % 5 WHEN ...`), which also makes `PrintVisitor`
102
+ output for such statements re-parseable
103
+ - fix `recipient_variable` being nil inside `solve` blocks, a regression
104
+ introduced in 3.5.4 (#333)
105
+ - fix crashes on two error paths: the parser's unbalanced-parenthesis report
106
+ raised a Ruby `ArgumentError` instead of a `ParseError`, and the tokenizer's
107
+ zero-width-match report raised a `KeyError` instead of a `TokenizerError`
108
+ - document Ruby compatibility policy
109
+ - modernize low-risk Ruby syntax
110
+ - remove Travis CI configuration
4
111
 
5
112
  ## [v3.5.8] 2026-08-01
6
113
  - 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,61 @@ function)
279
347
  Functions can be added individually using Calculator#add_function, or en masse
280
348
  using Calculator#add_functions.
281
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
+
386
+ Dentaku assumes registered functions are pure (see DEPENDENCY ANALYSIS AND
387
+ SHORT-CIRCUITING above). A function that reads external state, performs
388
+ I/O, or returns different values across calls must be declared volatile so
389
+ that dependency analysis never executes it:
390
+
391
+ ```ruby
392
+ > c = Dentaku::Calculator.new
393
+ > c.add_function(:user_level, :numeric, -> { Current.user.level }, volatile: true)
394
+ > c.identifiers('IF(user_level() > 50, high_rate, low_rate)')
395
+ #=> ["high_rate", "low_rate"]
396
+ ```
397
+
398
+ A volatile function in a guard position prevents branch pruning: both
399
+ branches count as dependencies, and `evaluate!` requires variables from
400
+ every branch (Dentaku cannot know in advance which branch will be taken).
401
+ The predicate itself is then evaluated exactly once per `evaluate!` call,
402
+ at evaluation time. In `add_functions`, pass volatility as a fifth tuple
403
+ element: `[:user_level, :numeric, -> { ... }, nil, true]`.
404
+
282
405
  FUNCTION ALIASES
283
406
  ----------------
284
407
 
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
@@ -38,10 +38,21 @@ module Dentaku
38
38
  l = cast(left_value)
39
39
  r = cast(right_value)
40
40
 
41
- l.public_send(operator, r)
42
- rescue ::TypeError => e
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
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
- return true if node.dependencies.any?
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)
@@ -97,14 +111,13 @@ module Dentaku
97
111
 
98
112
  def validate_operation(val)
99
113
  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}"
114
+ raise Dentaku::ArgumentError.for(:invalid_operator, operation: self.class, operator: operator)
102
115
  end
103
116
  end
104
117
 
105
118
  def validate_format(string)
106
119
  unless Dentaku::NumericParser.match(string)
107
- raise Dentaku::ArgumentError.for(:invalid_value, value: string, for: BigDecimal),
120
+ raise Dentaku::ArgumentError.for(:invalid_value, actual: string, expected: BigDecimal),
108
121
  "String input '#{string}' is not coercible to numeric"
109
122
  end
110
123
  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