Soroban 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.rspec +1 -0
- data/.yardopts +6 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +44 -0
- data/LICENSE.txt +20 -0
- data/README.md +115 -0
- data/Rakefile +42 -0
- data/VERSION +1 -0
- data/lib/soroban.rb +3 -0
- data/lib/soroban/cell.rb +47 -0
- data/lib/soroban/error.rb +20 -0
- data/lib/soroban/functions.rb +31 -0
- data/lib/soroban/functions/and.rb +4 -0
- data/lib/soroban/functions/average.rb +5 -0
- data/lib/soroban/functions/if.rb +4 -0
- data/lib/soroban/functions/max.rb +4 -0
- data/lib/soroban/functions/min.rb +4 -0
- data/lib/soroban/functions/not.rb +4 -0
- data/lib/soroban/functions/or.rb +4 -0
- data/lib/soroban/functions/sum.rb +4 -0
- data/lib/soroban/functions/vlookup.rb +10 -0
- data/lib/soroban/helpers.rb +45 -0
- data/lib/soroban/parser.rb +15 -0
- data/lib/soroban/parser/grammar.rb +1814 -0
- data/lib/soroban/parser/grammar.treetop +68 -0
- data/lib/soroban/parser/nodes.rb +65 -0
- data/lib/soroban/parser/rewrite.rb +36 -0
- data/lib/soroban/sheet.rb +119 -0
- data/lib/soroban/walker.rb +26 -0
- data/spec/soroban_spec.rb +97 -0
- data/spec/spec_helper.rb +12 -0
- metadata +234 -0
data/.document
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/.yardopts
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
gem "treetop", "~> 1.4.10"
|
4
|
+
|
5
|
+
group :development do
|
6
|
+
gem "rspec", "~> 2.8.0"
|
7
|
+
gem "yard", "~> 0.7"
|
8
|
+
gem "rdoc", "~> 3.12"
|
9
|
+
gem "bundler", "~> 1.1.3"
|
10
|
+
gem "jeweler", "~> 1.8.3"
|
11
|
+
gem "rcov", ">= 0"
|
12
|
+
gem "rubyXL", "~> 1.2.7"
|
13
|
+
gem "redcarpet"
|
14
|
+
end
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
diff-lcs (1.1.3)
|
5
|
+
git (1.2.5)
|
6
|
+
jeweler (1.8.3)
|
7
|
+
bundler (~> 1.0)
|
8
|
+
git (>= 1.2.5)
|
9
|
+
rake
|
10
|
+
rdoc
|
11
|
+
json (1.6.6)
|
12
|
+
polyglot (0.3.3)
|
13
|
+
rake (0.9.2.2)
|
14
|
+
rcov (1.0.0)
|
15
|
+
rdoc (3.12)
|
16
|
+
json (~> 1.4)
|
17
|
+
redcarpet (2.1.1)
|
18
|
+
rspec (2.8.0)
|
19
|
+
rspec-core (~> 2.8.0)
|
20
|
+
rspec-expectations (~> 2.8.0)
|
21
|
+
rspec-mocks (~> 2.8.0)
|
22
|
+
rspec-core (2.8.0)
|
23
|
+
rspec-expectations (2.8.0)
|
24
|
+
diff-lcs (~> 1.1.2)
|
25
|
+
rspec-mocks (2.8.0)
|
26
|
+
rubyXL (1.2.7)
|
27
|
+
treetop (1.4.10)
|
28
|
+
polyglot
|
29
|
+
polyglot (>= 0.3.1)
|
30
|
+
yard (0.7.5)
|
31
|
+
|
32
|
+
PLATFORMS
|
33
|
+
ruby
|
34
|
+
|
35
|
+
DEPENDENCIES
|
36
|
+
bundler (~> 1.1.3)
|
37
|
+
jeweler (~> 1.8.3)
|
38
|
+
rcov
|
39
|
+
rdoc (~> 3.12)
|
40
|
+
redcarpet
|
41
|
+
rspec (~> 2.8.0)
|
42
|
+
rubyXL (~> 1.2.7)
|
43
|
+
treetop (~> 1.4.10)
|
44
|
+
yard (~> 0.7)
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2012 Agworld Pty. Ltd.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
Soroban
|
2
|
+
=======
|
3
|
+
|
4
|
+
Soroban is a calculating engine that understands Excel formulas.
|
5
|
+
|
6
|
+
Getting Started
|
7
|
+
---------------
|
8
|
+
|
9
|
+
```
|
10
|
+
> sudo gem install soroban
|
11
|
+
```
|
12
|
+
|
13
|
+
Example Usage
|
14
|
+
-------------
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
require 'soroban'
|
18
|
+
|
19
|
+
s = Soroban::Sheet.new()
|
20
|
+
|
21
|
+
s.A1 = 2
|
22
|
+
s.set('B1:B5', [1,2,3,4,5])
|
23
|
+
s.C1 = "=SUM(B1:B5) + A1 ^ 3"
|
24
|
+
s.C2 = "=IF(C1>25,'Large','Tiny')"
|
25
|
+
|
26
|
+
puts s.C1 # => 23
|
27
|
+
|
28
|
+
s.bind(:input, :A1)
|
29
|
+
s.bind(:output, :C2)
|
30
|
+
|
31
|
+
puts s.output # => "Tiny"
|
32
|
+
|
33
|
+
s.input = 3
|
34
|
+
|
35
|
+
puts s.output # => "Large"
|
36
|
+
puts s.C1 # => 42
|
37
|
+
```
|
38
|
+
|
39
|
+
Persistence
|
40
|
+
-----------
|
41
|
+
|
42
|
+
Soroban formulas are strings that begin with the `=` symbol. It is therefore
|
43
|
+
easy to persist them, which is mighty handy if you need to parse an Excel
|
44
|
+
spreadsheet, rip out formulas, store everything to a database and then perform
|
45
|
+
calculations based on user input.
|
46
|
+
|
47
|
+
Soroban makes this easy, as it can tell you which cells you need to add to make
|
48
|
+
it possible to do the calculations you want, and it can iterate over all the
|
49
|
+
cells you've defined, so you can easily rip them out for persistence.
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
s.F1 = "= E1 + SUM(D1:D5)"
|
53
|
+
|
54
|
+
s.missing # => [:E1, :D1, :D2, :D3, :D4, :D5]
|
55
|
+
|
56
|
+
s.E1 = "= D1 ^ D2"
|
57
|
+
s.set("D1:D5", [1,2,3,4,5])
|
58
|
+
|
59
|
+
s.missing # => []
|
60
|
+
|
61
|
+
s.cells # => {"F1"=>"= E1 + SUM(D1:D5)", "E1"=>"= D1 ^ D2", "D1"=>"1", "D2"=>"2", "D3"=>"3", "D4"=>"4", "D5"=>"5"}
|
62
|
+
```
|
63
|
+
|
64
|
+
This means parsing a file can be done as follows.
|
65
|
+
|
66
|
+
* Add the cells that correspond to inputs and outputs
|
67
|
+
* Add the cells reported by `missing` (and continue to do so until it's empty)
|
68
|
+
* Persist the hash returned by `cells`
|
69
|
+
|
70
|
+
Iteration
|
71
|
+
---------
|
72
|
+
|
73
|
+
Note that `cells` returns the label of the cell along with its raw contents. If
|
74
|
+
you want to iterate over cell values (including computed values of formulas),
|
75
|
+
then use `walk`.
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
s.set('D1:D5', [1,2,3,4,5])
|
79
|
+
s.walk('D1:D5').reduce(:+) # => 15
|
80
|
+
```
|
81
|
+
|
82
|
+
Functions
|
83
|
+
---------
|
84
|
+
|
85
|
+
Soroban implements some Excel functions, but you may find that you need more
|
86
|
+
than those. In that case, it's easy to add more.
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
Soroban::functions # => ["MIN", "VLOOKUP", "AND", "MAX", "OR", "NOT", "IF", "AVERAGE", "SUM"]
|
90
|
+
|
91
|
+
Soroban::define :FOO => lambda { |lo, hi|
|
92
|
+
raise ArgumentError if lo > hi
|
93
|
+
rand(hi-lo) + lo
|
94
|
+
}
|
95
|
+
|
96
|
+
s.g = "=FOO(10, 20)"
|
97
|
+
|
98
|
+
puts s.g # => 17
|
99
|
+
```
|
100
|
+
|
101
|
+
Contributing to Soroban
|
102
|
+
-----------------------
|
103
|
+
|
104
|
+
* Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
|
105
|
+
* Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
|
106
|
+
* Fork the project.
|
107
|
+
* Start a feature/bugfix branch.
|
108
|
+
* Commit and push until you are happy with your contribution.
|
109
|
+
* Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
|
110
|
+
* Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
|
111
|
+
|
112
|
+
Copyright
|
113
|
+
---------
|
114
|
+
|
115
|
+
Copyright (c) 2012 Agworld Pty. Ltd. See LICENSE.txt for further details.
|
data/Rakefile
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'bundler'
|
5
|
+
begin
|
6
|
+
Bundler.setup(:default, :development)
|
7
|
+
rescue Bundler::BundlerError => e
|
8
|
+
$stderr.puts e.message
|
9
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
10
|
+
exit e.status_code
|
11
|
+
end
|
12
|
+
require 'rake'
|
13
|
+
|
14
|
+
require 'jeweler'
|
15
|
+
Jeweler::Tasks.new do |gem|
|
16
|
+
# gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
|
17
|
+
gem.name = "Soroban"
|
18
|
+
gem.homepage = "http://github.com/jasonhutchens/soroban"
|
19
|
+
gem.license = "MIT"
|
20
|
+
gem.summary = "Soroban is a calculating engine that understands Excel formulas."
|
21
|
+
gem.description = "Soroban makes it easy to extract and execute formulas from Excel spreadsheets. It rewrites Excel formulas as Ruby expressions, and allows you to bind named variables to spreadsheet cells to easily manipulate inputs and capture outputs."
|
22
|
+
gem.email = "jason.hutchens@agworld.com.au"
|
23
|
+
gem.authors = ["Jason Hutchens"]
|
24
|
+
# dependencies defined in Gemfile
|
25
|
+
end
|
26
|
+
Jeweler::RubygemsDotOrgTasks.new
|
27
|
+
|
28
|
+
require 'rspec/core'
|
29
|
+
require 'rspec/core/rake_task'
|
30
|
+
RSpec::Core::RakeTask.new(:spec) do |spec|
|
31
|
+
spec.pattern = FileList['spec/**/*_spec.rb']
|
32
|
+
end
|
33
|
+
|
34
|
+
RSpec::Core::RakeTask.new(:rcov) do |spec|
|
35
|
+
spec.pattern = 'spec/**/*_spec.rb'
|
36
|
+
spec.rcov = true
|
37
|
+
end
|
38
|
+
|
39
|
+
task :default => :spec
|
40
|
+
|
41
|
+
require 'yard'
|
42
|
+
YARD::Rake::YardocTask.new
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
data/lib/soroban.rb
ADDED
data/lib/soroban/cell.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'soroban/parser'
|
2
|
+
|
3
|
+
module Soroban
|
4
|
+
|
5
|
+
# Represents a single cell in a sheet. This class is used internally, and
|
6
|
+
# isn't exposed to the caller. The cell stores the original string
|
7
|
+
# representation of its contents, and the executable Ruby version of same, as
|
8
|
+
# generated via a rewrite grammar. Cells also store their dependencies.
|
9
|
+
class Cell
|
10
|
+
attr_reader :excel, :ruby, :dependencies
|
11
|
+
|
12
|
+
# Cells are initialised with a binding to allow formulas to be executed
|
13
|
+
# within the context of the sheet which ownes the cell.
|
14
|
+
def initialize(binding)
|
15
|
+
@dependencies = []
|
16
|
+
@binding = binding
|
17
|
+
@touched = false
|
18
|
+
end
|
19
|
+
|
20
|
+
# Set the contents of a cell, and store the executable Ruby version.
|
21
|
+
def set(contents)
|
22
|
+
contents = contents.to_s
|
23
|
+
contents = "'#{contents}'" if Soroban::unknown?(contents)
|
24
|
+
@excel, @ruby = contents, _convert(contents)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Eval the Ruby version of the string contents within the context of the
|
28
|
+
# owning sheet. Will throw Soroban::RecursionError if recursion is detected.
|
29
|
+
def get
|
30
|
+
raise Soroban::RecursionError, "Loop detected when evaluating '#{@excel}'" if @touched
|
31
|
+
@touched = true
|
32
|
+
eval(@ruby, @binding)
|
33
|
+
ensure
|
34
|
+
@touched = false
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def _convert(contents)
|
40
|
+
tree = Soroban::parser.parse(contents)
|
41
|
+
raise Soroban::ParseError, Soroban::parser.failure_reason if tree.nil?
|
42
|
+
tree.convert(@dependencies.clear)
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Soroban
|
2
|
+
|
3
|
+
# Thrown if an invalid formula is assigned to a cell.
|
4
|
+
class ParseError < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
# Thrown if calculation of a cell's formula depends on the value of the same
|
8
|
+
# cell.
|
9
|
+
class RecursionError < StandardError
|
10
|
+
end
|
11
|
+
|
12
|
+
# Thrown if a referenced cell falls outside the limits of a supplied range.
|
13
|
+
class RangeError < StandardError
|
14
|
+
end
|
15
|
+
|
16
|
+
# Thrown is access is attempted to an undefined cell.
|
17
|
+
class UndefinedError < StandardError
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Soroban
|
2
|
+
|
3
|
+
# Define a new function.
|
4
|
+
def self.define(function_hash)
|
5
|
+
@@functions ||= {}
|
6
|
+
function_hash.each { |name, callback| @@functions[name] = callback }
|
7
|
+
end
|
8
|
+
|
9
|
+
# Return an array of all defined functions.
|
10
|
+
def self.functions
|
11
|
+
@@functions.keys.map { |f| f.to_s }
|
12
|
+
end
|
13
|
+
|
14
|
+
# Call the named function within the context of the specified sheet.
|
15
|
+
def self.call(sheet, name, *args)
|
16
|
+
function = name.upcase.to_sym
|
17
|
+
raise Soroban::UndefinedError, "No such function '#{function}'" unless @@functions[function]
|
18
|
+
sheet.instance_exec(*args, &@@functions[function])
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
require 'soroban/functions/average'
|
24
|
+
require 'soroban/functions/sum'
|
25
|
+
require 'soroban/functions/vlookup'
|
26
|
+
require 'soroban/functions/if'
|
27
|
+
require 'soroban/functions/and'
|
28
|
+
require 'soroban/functions/or'
|
29
|
+
require 'soroban/functions/not'
|
30
|
+
require 'soroban/functions/max'
|
31
|
+
require 'soroban/functions/min'
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Return a value from the supplied range by searching the first column for the
|
2
|
+
# supplied value, and then reading the result from the matching row.
|
3
|
+
Soroban::define :VLOOKUP => lambda { |value, range, col, inexact|
|
4
|
+
fc, fr, tc, tr = Soroban::getRange(range)
|
5
|
+
i = walk("#{fc}#{fr}:#{fc}#{tr}").find_index(value)
|
6
|
+
return nil if i.nil?
|
7
|
+
(0...i).each { fr.next! }
|
8
|
+
(1...col).each { fc.next! }
|
9
|
+
eval("@#{fc}#{fr}.get")
|
10
|
+
}
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'soroban/parser'
|
2
|
+
|
3
|
+
module Soroban
|
4
|
+
|
5
|
+
# Return true if the supplied data represents a formula.
|
6
|
+
def self.formula?(data)
|
7
|
+
data.to_s.slice(0..0) == '='
|
8
|
+
end
|
9
|
+
|
10
|
+
# Return true if the supplied data is a number.
|
11
|
+
def self.number?(data)
|
12
|
+
Float(data.to_s) && true rescue false
|
13
|
+
end
|
14
|
+
|
15
|
+
# Return true if the supplied data is a boolean.
|
16
|
+
def self.boolean?(data)
|
17
|
+
/^(true|false)$/i.match(data.to_s) && true || false
|
18
|
+
end
|
19
|
+
|
20
|
+
# Return true if the supplied data is a string.
|
21
|
+
def self.string?(data)
|
22
|
+
/^["](\"|[^"])*["]$/.match(data.to_s) && true || /^['][^']*[']$/.match(data.to_s) && true || false
|
23
|
+
end
|
24
|
+
|
25
|
+
# Return true if the supplied data is a range.
|
26
|
+
def self.range?(data)
|
27
|
+
/^([a-zA-Z]+)([\d]+):([a-zA-Z]+)([\d]+)$/.match(data.to_s) && true || false
|
28
|
+
end
|
29
|
+
|
30
|
+
# Return true if the supplied data is of no recognised format.
|
31
|
+
def self.unknown?(data)
|
32
|
+
!self.formula?(data) && !self.number?(data) && !self.boolean?(data) && !self.string?(data)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Return the components of a range.
|
36
|
+
def self.getRange(range)
|
37
|
+
/^([a-zA-Z]+)([\d]+):([a-zA-Z]+)([\d]+)$/.match(range.to_s).to_a[1..-1]
|
38
|
+
end
|
39
|
+
|
40
|
+
# Return an array of values for the supplied arguments (which may be numbers, labels and ranges).
|
41
|
+
def self.getValues(binding, *args)
|
42
|
+
args.map { |arg| Soroban::range?(arg) ? Walker.new(arg, binding).map : arg }.flatten
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|