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 ADDED
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ bin/*
5
+ pkg/*
6
+ vendor/*
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in dentaku.gemspec
4
+ gemspec
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
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ desc "Run specs"
5
+ task :spec do
6
+ RSpec::Core::RakeTask.new(:spec) do |t|
7
+ t.rspec_opts = %w{--colour --format progress}
8
+ t.pattern = 'spec/*_spec.rb'
9
+ end
10
+ end
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,14 @@
1
+ require "dentaku/calculator"
2
+ require "dentaku/version"
3
+
4
+ module Dentaku
5
+ def self.[](expression, data={})
6
+ calculator.evaluate(expression, data)
7
+ end
8
+
9
+ private
10
+
11
+ def self.calculator
12
+ @calculator ||= Dentaku::Calculator.new
13
+ end
14
+ end
@@ -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,3 @@
1
+ module Dentaku
2
+ VERSION = "0.1.0"
3
+ 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,11 @@
1
+ require 'dentaku'
2
+
3
+ describe Dentaku do
4
+ it 'should evaulate an expression' do
5
+ Dentaku['5+3'].should eql(8)
6
+ end
7
+
8
+ it 'should bind values to variables' do
9
+ Dentaku['oranges > 7', {:oranges => 10}].should be_true
10
+ end
11
+ 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
+
@@ -0,0 +1,10 @@
1
+ require 'dentaku/token'
2
+
3
+ describe Dentaku::Token do
4
+ it 'should have a category and a value' do
5
+ token = Dentaku::Token.new(:numeric, 5)
6
+ token.category.should eq(:numeric)
7
+ token.value.should eq(5)
8
+ token.is?(:numeric).should be_true
9
+ end
10
+ end
@@ -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