dentaku 0.1.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.
- data/.gitignore +6 -0
- data/Gemfile +4 -0
- data/README.md +81 -0
- data/Rakefile +10 -0
- data/dentaku.gemspec +24 -0
- data/lib/dentaku.rb +14 -0
- data/lib/dentaku/calculator.rb +68 -0
- data/lib/dentaku/evaluator.rb +97 -0
- data/lib/dentaku/token.rb +24 -0
- data/lib/dentaku/token_matcher.rb +31 -0
- data/lib/dentaku/token_scanner.rb +22 -0
- data/lib/dentaku/tokenizer.rb +67 -0
- data/lib/dentaku/version.rb +3 -0
- data/spec/calculator_spec.rb +52 -0
- data/spec/dentaku_spec.rb +11 -0
- data/spec/evaluator_spec.rb +96 -0
- data/spec/token_matcher_spec.rb +55 -0
- data/spec/token_scanner_spec.rb +23 -0
- data/spec/token_spec.rb +10 -0
- data/spec/tokenizer_spec.rb +69 -0
- metadata +85 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
Dentaku
|
2
|
+
=======
|
3
|
+
|
4
|
+
http://github.com/rubysolo/dentaku
|
5
|
+
|
6
|
+
DESCRIPTION
|
7
|
+
-----------
|
8
|
+
|
9
|
+
Dentaku is a parser and evaluator for a mathematical and logical formula
|
10
|
+
language that allows run-time binding of values to variables referenced in the
|
11
|
+
formulas.
|
12
|
+
|
13
|
+
EXAMPLE
|
14
|
+
-------
|
15
|
+
|
16
|
+
This is probably simplest to illustrate in code:
|
17
|
+
|
18
|
+
calculator = Dentaku::Calculator.new
|
19
|
+
calculator.evaluate('10 * 2')
|
20
|
+
=> 20
|
21
|
+
|
22
|
+
Okay, not terribly exciting. But what if you want to have a reference to a
|
23
|
+
variable, and evaluate it at run-time? Here's how that would look:
|
24
|
+
|
25
|
+
calculator.evaluate('kiwi + 5', :kiwi => 2)
|
26
|
+
=> 7
|
27
|
+
|
28
|
+
You can also store the variable values in the calculator's memory and then
|
29
|
+
evaluate expressions against those stored values:
|
30
|
+
|
31
|
+
calculator.store(:peaches => 15)
|
32
|
+
calculator.evaluate('peaches - 5')
|
33
|
+
=> 10
|
34
|
+
calculator.evaluate('peaches >= 15')
|
35
|
+
=> true
|
36
|
+
|
37
|
+
For maximum CS geekery, `bind` is an alias of `store`.
|
38
|
+
|
39
|
+
If you're too lazy to be building calculator objects, there's a module-method
|
40
|
+
shortcut just for you:
|
41
|
+
|
42
|
+
Dentaku['plums * 1.5', {:plums => 2}]
|
43
|
+
=> 3.0
|
44
|
+
|
45
|
+
|
46
|
+
SUPPORTED OPERATORS
|
47
|
+
-------------------
|
48
|
+
|
49
|
+
Math: `+ - * /`
|
50
|
+
Logic: `< > <= >= <> != = AND OR`
|
51
|
+
|
52
|
+
THANKS
|
53
|
+
------
|
54
|
+
|
55
|
+
Big thanks to [ElkStone Basements](http://www.elkstonebasements.com/) for
|
56
|
+
allowing me to extract and open source this code.
|
57
|
+
|
58
|
+
LICENSE
|
59
|
+
-------
|
60
|
+
|
61
|
+
(The MIT License)
|
62
|
+
|
63
|
+
Copyright © 2012 Solomon White
|
64
|
+
|
65
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
66
|
+
this software and associated documentation files (the ‘Software’), to deal in
|
67
|
+
the Software without restriction, including without limitation the rights to
|
68
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
69
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
70
|
+
subject to the following conditions:
|
71
|
+
|
72
|
+
The above copyright notice and this permission notice shall be included in all
|
73
|
+
copies or substantial portions of the Software.
|
74
|
+
|
75
|
+
THE SOFTWARE IS PROVIDED ‘AS IS’, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
76
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
77
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
78
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
79
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
80
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
81
|
+
|
data/Rakefile
ADDED
data/dentaku.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "dentaku/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "dentaku"
|
7
|
+
s.version = Dentaku::VERSION
|
8
|
+
s.authors = ["Solomon White"]
|
9
|
+
s.email = ["rubysolo@gmail.com"]
|
10
|
+
s.homepage = "http://github.com/rubysolo/dentaku"
|
11
|
+
s.summary = %q{A formula language parser and evaluator}
|
12
|
+
s.description = <<-DESC
|
13
|
+
Dentaku is a parser and evaluator for mathematical formulas
|
14
|
+
DESC
|
15
|
+
|
16
|
+
s.rubyforge_project = "dentaku"
|
17
|
+
|
18
|
+
s.add_development_dependency('rspec')
|
19
|
+
|
20
|
+
s.files = `git ls-files`.split("\n")
|
21
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
22
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
23
|
+
s.require_paths = ["lib"]
|
24
|
+
end
|
data/lib/dentaku.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'dentaku/evaluator'
|
2
|
+
require 'dentaku/token'
|
3
|
+
require 'dentaku/tokenizer'
|
4
|
+
|
5
|
+
module Dentaku
|
6
|
+
class Calculator
|
7
|
+
attr_reader :result
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
clear
|
11
|
+
end
|
12
|
+
|
13
|
+
def evaluate(expression, data={})
|
14
|
+
@tokenizer ||= Tokenizer.new
|
15
|
+
@tokens = @tokenizer.tokenize(expression)
|
16
|
+
|
17
|
+
store(data) do
|
18
|
+
@evaluator ||= Evaluator.new
|
19
|
+
@result = @evaluator.evaluate(replace_identifiers_with_values)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def memory(key=nil)
|
24
|
+
key ? @memory[key.to_sym] : @memory
|
25
|
+
end
|
26
|
+
|
27
|
+
def store(key_or_hash, value=nil)
|
28
|
+
restore = @memory.dup
|
29
|
+
|
30
|
+
if value
|
31
|
+
@memory[key_or_hash.to_sym] = value
|
32
|
+
else
|
33
|
+
key_or_hash.each do |key, value|
|
34
|
+
@memory[key.to_sym] = value if value
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
if block_given?
|
39
|
+
result = yield
|
40
|
+
@memory = restore
|
41
|
+
return result
|
42
|
+
end
|
43
|
+
|
44
|
+
self
|
45
|
+
end
|
46
|
+
alias_method :bind, :store
|
47
|
+
|
48
|
+
def clear
|
49
|
+
@memory = {}
|
50
|
+
end
|
51
|
+
|
52
|
+
def empty?
|
53
|
+
@memory.empty?
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def replace_identifiers_with_values
|
59
|
+
@tokens.map do |token|
|
60
|
+
if token.is?(:identifier)
|
61
|
+
Token.new(:numeric, memory(token.value))
|
62
|
+
else
|
63
|
+
token
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'dentaku/token'
|
2
|
+
require 'dentaku/token_matcher'
|
3
|
+
|
4
|
+
module Dentaku
|
5
|
+
class Evaluator
|
6
|
+
T_NUMERIC = TokenMatcher.new(:numeric)
|
7
|
+
T_ADDSUB = TokenMatcher.new(:operator, [:add, :subtract])
|
8
|
+
T_MULDIV = TokenMatcher.new(:operator, [:multiply, :divide])
|
9
|
+
T_COMPARATOR = TokenMatcher.new(:comparator)
|
10
|
+
T_OPEN = TokenMatcher.new(:grouping, :open)
|
11
|
+
T_CLOSE = TokenMatcher.new(:grouping, :close)
|
12
|
+
T_NON_GROUP = TokenMatcher.new(:grouping).invert
|
13
|
+
T_LOGICAL = TokenMatcher.new(:logical)
|
14
|
+
T_COMBINATOR = TokenMatcher.new(:combinator)
|
15
|
+
|
16
|
+
P_GROUP = [T_OPEN, T_NON_GROUP, T_CLOSE]
|
17
|
+
P_MATH_ADD = [T_NUMERIC, T_ADDSUB, T_NUMERIC]
|
18
|
+
P_MATH_MUL = [T_NUMERIC, T_MULDIV, T_NUMERIC]
|
19
|
+
P_COMPARISON = [T_NUMERIC, T_COMPARATOR, T_NUMERIC]
|
20
|
+
P_COMBINE = [T_LOGICAL, T_COMBINATOR, T_LOGICAL]
|
21
|
+
|
22
|
+
RULES = [
|
23
|
+
[P_GROUP, :evaluate_group],
|
24
|
+
[P_MATH_MUL, :apply],
|
25
|
+
[P_MATH_ADD, :apply],
|
26
|
+
[P_COMPARISON, :apply],
|
27
|
+
[P_COMBINE, :apply]
|
28
|
+
]
|
29
|
+
|
30
|
+
def evaluate(tokens)
|
31
|
+
evaluate_token_stream(tokens).value
|
32
|
+
end
|
33
|
+
|
34
|
+
def evaluate_token_stream(tokens)
|
35
|
+
while tokens.length > 1
|
36
|
+
matched = false
|
37
|
+
RULES.each do |pattern, evaluator|
|
38
|
+
if pos = find_rule_match(pattern, tokens)
|
39
|
+
tokens = evaluate_step(tokens, pos, pattern.length, evaluator)
|
40
|
+
matched = true
|
41
|
+
break
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
raise "no rule matched #{ tokens.map(&:category).inspect }" unless matched
|
46
|
+
end
|
47
|
+
|
48
|
+
tokens << Token.new(:numeric, 0) if tokens.empty?
|
49
|
+
|
50
|
+
tokens.first
|
51
|
+
end
|
52
|
+
|
53
|
+
def evaluate_step(token_stream, start, length, evaluator)
|
54
|
+
expr = token_stream.slice!(start, length)
|
55
|
+
token_stream.insert start, self.send(evaluator, *expr)
|
56
|
+
end
|
57
|
+
|
58
|
+
def find_rule_match(pattern, token_stream)
|
59
|
+
position = 0
|
60
|
+
while position <= token_stream.length - pattern.length
|
61
|
+
substream = token_stream.slice(position, pattern.length)
|
62
|
+
return position if pattern == substream
|
63
|
+
position += 1
|
64
|
+
end
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
|
68
|
+
def evaluate_group(*args)
|
69
|
+
evaluate_token_stream(args[1..-2])
|
70
|
+
end
|
71
|
+
|
72
|
+
def apply(lvalue, operator, rvalue)
|
73
|
+
l = lvalue.value
|
74
|
+
r = rvalue.value
|
75
|
+
|
76
|
+
case operator.value
|
77
|
+
when :add then Token.new(:numeric, l + r)
|
78
|
+
when :subtract then Token.new(:numeric, l - r)
|
79
|
+
when :multiply then Token.new(:numeric, l * r)
|
80
|
+
when :divide then Token.new(:numeric, l / r)
|
81
|
+
|
82
|
+
when :le then Token.new(:logical, l <= r)
|
83
|
+
when :ge then Token.new(:logical, l >= r)
|
84
|
+
when :lt then Token.new(:logical, l < r)
|
85
|
+
when :gt then Token.new(:logical, l > r)
|
86
|
+
when :ne then Token.new(:logical, l != r)
|
87
|
+
when :eq then Token.new(:logical, l == r)
|
88
|
+
|
89
|
+
when :and then Token.new(:logical, l && r)
|
90
|
+
when :or then Token.new(:logical, l || r)
|
91
|
+
|
92
|
+
else
|
93
|
+
raise "unknown comparator '#{ comparator }'"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Dentaku
|
2
|
+
class Token
|
3
|
+
attr_reader :category, :raw_value, :value
|
4
|
+
|
5
|
+
def initialize(category, value, raw_value=nil)
|
6
|
+
@category = category
|
7
|
+
@value = value
|
8
|
+
@raw_value = raw_value
|
9
|
+
end
|
10
|
+
|
11
|
+
def length
|
12
|
+
raw_value.to_s.length
|
13
|
+
end
|
14
|
+
|
15
|
+
def is?(c)
|
16
|
+
category == c
|
17
|
+
end
|
18
|
+
|
19
|
+
def ==(other)
|
20
|
+
(category.nil? || other.category.nil? || category == other.category) &&
|
21
|
+
(value.nil? || other.value.nil? || value == other.value)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'dentaku/token'
|
2
|
+
|
3
|
+
module Dentaku
|
4
|
+
class TokenMatcher
|
5
|
+
def initialize(categories=nil, values=nil)
|
6
|
+
@categories = [categories].compact.flatten
|
7
|
+
@values = [values].compact.flatten
|
8
|
+
@invert = false
|
9
|
+
end
|
10
|
+
|
11
|
+
def invert
|
12
|
+
@invert = ! @invert
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
def ==(token)
|
17
|
+
(category_match(token.category) && value_match(token.value)) ^ @invert
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def category_match(category)
|
23
|
+
@categories.empty? || @categories.include?(category)
|
24
|
+
end
|
25
|
+
|
26
|
+
def value_match(value)
|
27
|
+
@values.empty? || @values.include?(value)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'dentaku/token'
|
2
|
+
|
3
|
+
module Dentaku
|
4
|
+
class TokenScanner
|
5
|
+
def initialize(category, regexp, converter=nil)
|
6
|
+
@category = category
|
7
|
+
@regexp = %r{\A(#{ regexp })}
|
8
|
+
@converter = converter
|
9
|
+
end
|
10
|
+
|
11
|
+
def scan(string)
|
12
|
+
if m = @regexp.match(string)
|
13
|
+
value = raw = m.to_s
|
14
|
+
value = @converter.call(raw) if @converter
|
15
|
+
|
16
|
+
return Token.new(@category, value, raw)
|
17
|
+
end
|
18
|
+
|
19
|
+
false
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'dentaku/token'
|
2
|
+
require 'dentaku/token_matcher'
|
3
|
+
require 'dentaku/token_scanner'
|
4
|
+
|
5
|
+
module Dentaku
|
6
|
+
class Tokenizer
|
7
|
+
SCANNERS = [
|
8
|
+
TokenScanner.new(:whitespace, '\s+'),
|
9
|
+
TokenScanner.new(:numeric, '(\d+(\.\d+)?|\.\d+)', lambda{|raw| raw =~ /\./ ? raw.to_f : raw.to_i }),
|
10
|
+
TokenScanner.new(:operator, '\+|-|\*|\/', lambda do |raw|
|
11
|
+
case raw
|
12
|
+
when '+' then :add
|
13
|
+
when '-' then :subtract
|
14
|
+
when '*' then :multiply
|
15
|
+
when '/' then :divide
|
16
|
+
end
|
17
|
+
end),
|
18
|
+
TokenScanner.new(:grouping, '\(|\)', lambda do |raw|
|
19
|
+
raw == '(' ? :open : :close
|
20
|
+
end),
|
21
|
+
TokenScanner.new(:comparator, '<=|>=|!=|<>|<|>|=', lambda do |raw|
|
22
|
+
case raw
|
23
|
+
when '<=' then :le
|
24
|
+
when '>=' then :ge
|
25
|
+
when '!=' then :ne
|
26
|
+
when '<>' then :ne
|
27
|
+
when '<' then :lt
|
28
|
+
when '>' then :gt
|
29
|
+
when '=' then :eq
|
30
|
+
end
|
31
|
+
end),
|
32
|
+
TokenScanner.new(:combinator, '(and|or)\b', lambda {|raw| raw.strip.to_sym }),
|
33
|
+
TokenScanner.new(:identifier, '[A-Za-z_]+', lambda {|raw| raw.to_sym })
|
34
|
+
]
|
35
|
+
|
36
|
+
LPAREN = TokenMatcher.new(:grouping, :open)
|
37
|
+
RPAREN = TokenMatcher.new(:grouping, :close)
|
38
|
+
|
39
|
+
def tokenize(string)
|
40
|
+
nesting = 0
|
41
|
+
tokens = []
|
42
|
+
input = string.dup.downcase
|
43
|
+
|
44
|
+
until input.empty?
|
45
|
+
raise "parse error at: '#{ input }'" unless SCANNERS.any? do |scanner|
|
46
|
+
if token = scanner.scan(input)
|
47
|
+
raise "unexpected zero-width match (:#{ token.category }) at '#{ input }'" if token.length == 0
|
48
|
+
|
49
|
+
nesting += 1 if LPAREN == token
|
50
|
+
nesting -= 1 if RPAREN == token
|
51
|
+
raise "too many closing parentheses" if nesting < 0
|
52
|
+
|
53
|
+
tokens << token unless token.is?(:whitespace)
|
54
|
+
input.slice!(0, token.length)
|
55
|
+
|
56
|
+
true
|
57
|
+
else
|
58
|
+
false
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
raise "too many opening parentheses" if nesting > 0
|
64
|
+
tokens
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'dentaku/calculator'
|
2
|
+
|
3
|
+
describe Dentaku::Calculator do
|
4
|
+
let(:calculator) { described_class.new }
|
5
|
+
let(:with_memory) { described_class.new.store(:apples => 3) }
|
6
|
+
|
7
|
+
it 'evaluates an expression' do
|
8
|
+
calculator.evaluate('7+3').should eq(10)
|
9
|
+
end
|
10
|
+
|
11
|
+
describe 'memory' do
|
12
|
+
it { calculator.should be_empty }
|
13
|
+
it { with_memory.should_not be_empty }
|
14
|
+
it { with_memory.clear.should be_empty }
|
15
|
+
|
16
|
+
it { with_memory.memory(:apples).should eq(3) }
|
17
|
+
it { with_memory.memory('apples').should eq(3) }
|
18
|
+
|
19
|
+
it { calculator.store(:apples, 3).memory('apples').should eq(3) }
|
20
|
+
it { calculator.store('apples', 3).memory(:apples).should eq(3) }
|
21
|
+
|
22
|
+
it 'should discard local values' do
|
23
|
+
calculator.evaluate('pears * 2', :pears => 5).should eq(10)
|
24
|
+
calculator.should be_empty
|
25
|
+
lambda { calculator.tokenize('pears * 2') }.should raise_error
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'should evaluate a statement with no variables' do
|
30
|
+
calculator.evaluate('5+3').should eq(8)
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'should fail to evaluate unbound statements' do
|
34
|
+
lambda { calculator.evaluate('foo * 1.5') }.should raise_error
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'should evaluate unbound statements given a binding in memory' do
|
38
|
+
calculator.evaluate('foo * 1.5', :foo => 2).should eq(3)
|
39
|
+
calculator.bind(:monkeys => 3).evaluate('monkeys < 7').should be_true
|
40
|
+
calculator.evaluate('monkeys / 1.5').should eq(2)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'should rebind for each evaluation' do
|
44
|
+
calculator.evaluate('foo * 2', :foo => 2).should eq(4)
|
45
|
+
calculator.evaluate('foo * 2', :foo => 4).should eq(8)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'should accept strings or symbols for binding keys' do
|
49
|
+
calculator.evaluate('foo * 2', :foo => 2).should eq(4)
|
50
|
+
calculator.evaluate('foo * 2', 'foo' => 4).should eq(8)
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'dentaku/evaluator'
|
2
|
+
|
3
|
+
describe Dentaku::Evaluator do
|
4
|
+
let(:evaluator) { Dentaku::Evaluator.new }
|
5
|
+
|
6
|
+
describe 'rule scanning' do
|
7
|
+
it 'should find a matching rule' do
|
8
|
+
rule = [Dentaku::Token.new(:numeric, nil)]
|
9
|
+
stream = [Dentaku::Token.new(:numeric, 1), Dentaku::Token.new(:operator, :add), Dentaku::Token.new(:numeric, 1)]
|
10
|
+
evaluator.find_rule_match(rule, stream).should eq(0)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe 'evaluating' do
|
15
|
+
it 'empty expression should be truthy' do
|
16
|
+
evaluator.evaluate([]).should be
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'empty expression should equal 0' do
|
20
|
+
evaluator.evaluate([]).should eq(0)
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'single numeric should return value' do
|
24
|
+
evaluator.evaluate([Dentaku::Token.new(:numeric, 10)]).should eq(10)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'should evaluate one apply step' do
|
28
|
+
stream = ts(1, :add, 1, :add, 1)
|
29
|
+
expected = ts(2, :add, 1)
|
30
|
+
|
31
|
+
evaluator.evaluate_step(stream, 0, 3, :apply).should eq(expected)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should evaluate one grouping step' do
|
35
|
+
stream = ts(:open, 1, :add, 1, :close, :multiply, 5)
|
36
|
+
expected = ts(2, :multiply, 5)
|
37
|
+
|
38
|
+
evaluator.evaluate_step(stream, 0, 5, :evaluate_group).should eq(expected)
|
39
|
+
end
|
40
|
+
|
41
|
+
describe 'maths' do
|
42
|
+
it 'should perform addition' do
|
43
|
+
evaluator.evaluate(ts(1, :add, 1)).should eq(2)
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'should respect order of precedence' do
|
47
|
+
evaluator.evaluate(ts(1, :add, 1, :multiply, 5)).should eq(6)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'should respect explicit grouping' do
|
51
|
+
evaluator.evaluate(ts(:open, 1, :add, 1, :close, :multiply, 5)).should eq(10)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe 'logic' do
|
56
|
+
it 'should evaluate conditional' do
|
57
|
+
evaluator.evaluate(ts(5, :gt, 1)).should be_true
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'should evaluate combined conditionals' do
|
61
|
+
evaluator.evaluate(ts(5, :gt, 1, :or, :false)).should be_true
|
62
|
+
evaluator.evaluate(ts(5, :gt, 1, :and, :false)).should be_false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def ts(*args)
|
70
|
+
args.map do |arg|
|
71
|
+
category = (arg.is_a? Fixnum) ? :numeric : category_for(arg)
|
72
|
+
arg = (arg == :true) if category == :logical
|
73
|
+
Dentaku::Token.new(category, arg)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def category_for(value)
|
78
|
+
case value
|
79
|
+
when Numeric
|
80
|
+
:numeric
|
81
|
+
when :add, :subtract, :multiply, :divide
|
82
|
+
:operator
|
83
|
+
when :open, :close
|
84
|
+
:grouping
|
85
|
+
when :le, :ge, :ne, :ne, :lt, :gt, :eq
|
86
|
+
:comparator
|
87
|
+
when :and, :or
|
88
|
+
:combinator
|
89
|
+
when :true, :false
|
90
|
+
:logical
|
91
|
+
else
|
92
|
+
:identifier
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'dentaku/token_matcher'
|
2
|
+
|
3
|
+
describe Dentaku::TokenMatcher do
|
4
|
+
it 'with single category should match token category' do
|
5
|
+
matcher = described_class.new(:numeric)
|
6
|
+
token = Dentaku::Token.new(:numeric, 5)
|
7
|
+
|
8
|
+
matcher.should == token
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'with multiple categories should match any included token category' do
|
12
|
+
matcher = described_class.new([:comparator, :operator])
|
13
|
+
numeric = Dentaku::Token.new(:numeric, 5)
|
14
|
+
comparator = Dentaku::Token.new(:comparator, :lt)
|
15
|
+
operator = Dentaku::Token.new(:operator, :add)
|
16
|
+
|
17
|
+
matcher.should == comparator
|
18
|
+
matcher.should == operator
|
19
|
+
matcher.should_not == numeric
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'with single category and value should match token category and value' do
|
23
|
+
matcher = described_class.new(:operator, :add)
|
24
|
+
addition = Dentaku::Token.new(:operator, :add)
|
25
|
+
subtraction = Dentaku::Token.new(:operator, :subtract)
|
26
|
+
|
27
|
+
matcher.should == addition
|
28
|
+
matcher.should_not == subtraction
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'with multiple values should match any included token value' do
|
32
|
+
matcher = described_class.new(:operator, [:add, :subtract])
|
33
|
+
add = Dentaku::Token.new(:operator, :add)
|
34
|
+
sub = Dentaku::Token.new(:operator, :subtract)
|
35
|
+
mul = Dentaku::Token.new(:operator, :multiply)
|
36
|
+
div = Dentaku::Token.new(:operator, :divide)
|
37
|
+
|
38
|
+
matcher.should == add
|
39
|
+
matcher.should == sub
|
40
|
+
matcher.should_not == mul
|
41
|
+
matcher.should_not == div
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'should be invertible' do
|
45
|
+
matcher = described_class.new(:operator, [:add, :subtract]).invert
|
46
|
+
add = Dentaku::Token.new(:operator, :add)
|
47
|
+
mul = Dentaku::Token.new(:operator, :multiply)
|
48
|
+
cmp = Dentaku::Token.new(:comparator, :lt)
|
49
|
+
|
50
|
+
matcher.should_not == add
|
51
|
+
matcher.should == mul
|
52
|
+
matcher.should == cmp
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'dentaku/token_scanner'
|
2
|
+
|
3
|
+
describe Dentaku::TokenScanner do
|
4
|
+
let(:whitespace) { described_class.new(:whitespace, '\s') }
|
5
|
+
let(:numeric) { described_class.new(:numeric, '(\d+(\.\d+)?|\.\d+)', lambda{|raw| raw =~ /\./ ? raw.to_f : raw.to_i }) }
|
6
|
+
|
7
|
+
it 'should return a token for a matching string' do
|
8
|
+
token = whitespace.scan(' ')
|
9
|
+
token.category.should eq(:whitespace)
|
10
|
+
token.value.should eq(' ')
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'should return falsy for a non-matching string' do
|
14
|
+
whitespace.scan('A').should_not be
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'should perform raw value conversion' do
|
18
|
+
token = numeric.scan('5')
|
19
|
+
token.category.should eq(:numeric)
|
20
|
+
token.value.should eq(5)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
data/spec/token_spec.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'dentaku/tokenizer'
|
2
|
+
|
3
|
+
describe Dentaku::Tokenizer do
|
4
|
+
let(:tokenizer) { described_class.new }
|
5
|
+
|
6
|
+
it 'should handle an empty expression' do
|
7
|
+
tokenizer.tokenize('').should be_empty
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'should tokenize addition' do
|
11
|
+
tokens = tokenizer.tokenize('1+1')
|
12
|
+
tokens.map(&:category).should eq([:numeric, :operator, :numeric])
|
13
|
+
tokens.map(&:value).should eq([1, :add, 1])
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should ignore whitespace' do
|
17
|
+
tokens = tokenizer.tokenize('1 / 1 ')
|
18
|
+
tokens.map(&:category).should eq([:numeric, :operator, :numeric])
|
19
|
+
tokens.map(&:value).should eq([1, :divide, 1])
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'should handle floating point' do
|
23
|
+
tokens = tokenizer.tokenize('1.5 * 3.7')
|
24
|
+
tokens.map(&:category).should eq([:numeric, :operator, :numeric])
|
25
|
+
tokens.map(&:value).should eq([1.5, :multiply, 3.7])
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'should not require leading zero' do
|
29
|
+
tokens = tokenizer.tokenize('.5 * 3.7')
|
30
|
+
tokens.map(&:category).should eq([:numeric, :operator, :numeric])
|
31
|
+
tokens.map(&:value).should eq([0.5, :multiply, 3.7])
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should accept arbitrary identifiers' do
|
35
|
+
tokens = tokenizer.tokenize('monkeys > 1500')
|
36
|
+
tokens.map(&:category).should eq([:identifier, :comparator, :numeric])
|
37
|
+
tokens.map(&:value).should eq([:monkeys, :gt, 1500])
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'should match "<=" before "<"' do
|
41
|
+
tokens = tokenizer.tokenize('perimeter <= 7500')
|
42
|
+
tokens.map(&:category).should eq([:identifier, :comparator, :numeric])
|
43
|
+
tokens.map(&:value).should eq([:perimeter, :le, 7500])
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'should match "and" for logical expressions' do
|
47
|
+
tokens = tokenizer.tokenize('octopi <= 7500 AND sharks > 1500')
|
48
|
+
tokens.map(&:category).should eq([:identifier, :comparator, :numeric, :combinator, :identifier, :comparator, :numeric])
|
49
|
+
tokens.map(&:value).should eq([:octopi, :le, 7500, :and, :sharks, :gt, 1500])
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'should match "or" for logical expressions' do
|
53
|
+
tokens = tokenizer.tokenize('size < 3 or admin = 1')
|
54
|
+
tokens.map(&:category).should eq([:identifier, :comparator, :numeric, :combinator, :identifier, :comparator, :numeric])
|
55
|
+
tokens.map(&:value).should eq([:size, :lt, 3, :or, :admin, :eq, 1])
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'should detect unbalanced parentheses' do
|
59
|
+
lambda { tokenizer.tokenize('(5+3') }.should raise_error
|
60
|
+
lambda { tokenizer.tokenize(')') }.should raise_error
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'should recognize identifiers that share initial substrings with combinators' do
|
64
|
+
tokens = tokenizer.tokenize('andover < 10')
|
65
|
+
tokens.length.should eq(3)
|
66
|
+
tokens.map(&:category).should eq([:identifier, :comparator, :numeric])
|
67
|
+
tokens.map(&:value).should eq([:andover, :lt, 10])
|
68
|
+
end
|
69
|
+
end
|
metadata
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dentaku
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Solomon White
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-01-20 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rspec
|
16
|
+
requirement: &70125220014660 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70125220014660
|
25
|
+
description: ! ' Dentaku is a parser and evaluator for mathematical formulas
|
26
|
+
|
27
|
+
'
|
28
|
+
email:
|
29
|
+
- rubysolo@gmail.com
|
30
|
+
executables: []
|
31
|
+
extensions: []
|
32
|
+
extra_rdoc_files: []
|
33
|
+
files:
|
34
|
+
- .gitignore
|
35
|
+
- Gemfile
|
36
|
+
- README.md
|
37
|
+
- Rakefile
|
38
|
+
- dentaku.gemspec
|
39
|
+
- lib/dentaku.rb
|
40
|
+
- lib/dentaku/calculator.rb
|
41
|
+
- lib/dentaku/evaluator.rb
|
42
|
+
- lib/dentaku/token.rb
|
43
|
+
- lib/dentaku/token_matcher.rb
|
44
|
+
- lib/dentaku/token_scanner.rb
|
45
|
+
- lib/dentaku/tokenizer.rb
|
46
|
+
- lib/dentaku/version.rb
|
47
|
+
- spec/calculator_spec.rb
|
48
|
+
- spec/dentaku_spec.rb
|
49
|
+
- spec/evaluator_spec.rb
|
50
|
+
- spec/token_matcher_spec.rb
|
51
|
+
- spec/token_scanner_spec.rb
|
52
|
+
- spec/token_spec.rb
|
53
|
+
- spec/tokenizer_spec.rb
|
54
|
+
homepage: http://github.com/rubysolo/dentaku
|
55
|
+
licenses: []
|
56
|
+
post_install_message:
|
57
|
+
rdoc_options: []
|
58
|
+
require_paths:
|
59
|
+
- lib
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
|
+
none: false
|
68
|
+
requirements:
|
69
|
+
- - ! '>='
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: '0'
|
72
|
+
requirements: []
|
73
|
+
rubyforge_project: dentaku
|
74
|
+
rubygems_version: 1.8.10
|
75
|
+
signing_key:
|
76
|
+
specification_version: 3
|
77
|
+
summary: A formula language parser and evaluator
|
78
|
+
test_files:
|
79
|
+
- spec/calculator_spec.rb
|
80
|
+
- spec/dentaku_spec.rb
|
81
|
+
- spec/evaluator_spec.rb
|
82
|
+
- spec/token_matcher_spec.rb
|
83
|
+
- spec/token_scanner_spec.rb
|
84
|
+
- spec/token_spec.rb
|
85
|
+
- spec/tokenizer_spec.rb
|