dentaku 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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