soroban 0.7.3 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +4 -3
- data/README.md +4 -4
- data/Soroban.gemspec +3 -3
- data/VERSION +1 -1
- data/lib/soroban.rb +1 -2
- data/lib/soroban/cell.rb +47 -18
- data/lib/soroban/errors.rb +20 -0
- data/lib/soroban/functions.rb +31 -21
- data/lib/soroban/functions/and.rb +4 -2
- data/lib/soroban/functions/average.rb +2 -2
- data/lib/soroban/functions/exp.rb +1 -3
- data/lib/soroban/functions/if.rb +1 -1
- data/lib/soroban/functions/ln.rb +1 -3
- data/lib/soroban/functions/max.rb +2 -2
- data/lib/soroban/functions/min.rb +2 -2
- data/lib/soroban/functions/not.rb +1 -3
- data/lib/soroban/functions/or.rb +4 -2
- data/lib/soroban/functions/sum.rb +2 -2
- data/lib/soroban/functions/vlookup.rb +2 -2
- data/lib/soroban/helpers.rb +62 -38
- data/lib/soroban/import.rb +4 -1
- data/lib/soroban/import/ruby_xl_importer.rb +21 -15
- data/lib/soroban/import/ruby_xl_patch.rb +2 -0
- data/lib/soroban/label_walker.rb +10 -9
- data/lib/soroban/parser.rb +6 -4
- data/lib/soroban/parser/grammar.rb +1474 -1472
- data/lib/soroban/parser/grammar.treetop +71 -67
- data/lib/soroban/parser/nodes.rb +49 -47
- data/lib/soroban/parser/rewrite.rb +20 -13
- data/lib/soroban/sheet.rb +33 -29
- data/lib/soroban/value_walker.rb +19 -19
- data/spec/documentation_spec.rb +31 -43
- data/spec/import_spec.rb +5 -13
- data/spec/soroban_spec.rb +6 -2
- data/spec/spec_helper.rb +8 -0
- metadata +6 -6
- data/lib/soroban/error.rb +0 -20
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -71,12 +71,12 @@ cells you've defined, so you can easily rip them out for persistence.
|
|
71
71
|
```ruby
|
72
72
|
s.F1 = "= E1 + SUM(D1:D5)"
|
73
73
|
|
74
|
-
s.missing
|
74
|
+
puts s.missing # => [:E1, :D1, :D2, :D3, :D4, :D5]
|
75
75
|
|
76
76
|
s.E1 = "= D1 ^ D2"
|
77
77
|
s.set("D1:D5" => [1,2,3,4,5])
|
78
78
|
|
79
|
-
s.missing # => []
|
79
|
+
puts s.missing # => []
|
80
80
|
|
81
81
|
s.cells # => {:F1=>"= E1 + SUM(D1:D5)", :E1=>"= D1 ^ D2", :D1=>"1", :D2=>"2", :D3=>"3", :D4=>"4", :D5=>"5"}
|
82
82
|
```
|
@@ -93,7 +93,7 @@ BINDINGS = {
|
|
93
93
|
:force => :B3
|
94
94
|
}
|
95
95
|
|
96
|
-
s = Soroban::Import::rubyXL("files/Physics.xlsx", 0, BINDINGS
|
96
|
+
s = Soroban::Import::rubyXL("files/Physics.xlsx", 0, BINDINGS)
|
97
97
|
|
98
98
|
s.planet = 'Earth'
|
99
99
|
s.mass = 80
|
@@ -135,7 +135,7 @@ Soroban implements some Excel functions, but you may find that you need more
|
|
135
135
|
than those. In that case, it's easy to add more.
|
136
136
|
|
137
137
|
```ruby
|
138
|
-
Soroban::functions # => ["
|
138
|
+
Soroban::functions # => ["AND", "AVERAGE", "EXP", "IF", "LN", "MAX", "MIN", "NOT", "OR", "SUM", "VLOOKUP"]
|
139
139
|
|
140
140
|
Soroban::define :FOO => lambda { |lo, hi|
|
141
141
|
raise ArgumentError if lo > hi
|
data/Soroban.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = "soroban"
|
8
|
-
s.version = "0.
|
8
|
+
s.version = "0.8.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Jason Hutchens"]
|
12
|
-
s.date = "2014-03-
|
12
|
+
s.date = "2014-03-25"
|
13
13
|
s.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."
|
14
14
|
s.email = "jason.hutchens@agworld.com.au"
|
15
15
|
s.extra_rdoc_files = [
|
@@ -31,7 +31,7 @@ Gem::Specification.new do |s|
|
|
31
31
|
"files/Physics.xlsx",
|
32
32
|
"lib/soroban.rb",
|
33
33
|
"lib/soroban/cell.rb",
|
34
|
-
"lib/soroban/
|
34
|
+
"lib/soroban/errors.rb",
|
35
35
|
"lib/soroban/functions.rb",
|
36
36
|
"lib/soroban/functions/and.rb",
|
37
37
|
"lib/soroban/functions/average.rb",
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.8.0
|
data/lib/soroban.rb
CHANGED
data/lib/soroban/cell.rb
CHANGED
@@ -1,3 +1,9 @@
|
|
1
|
+
unless defined?(Set)
|
2
|
+
require 'set'
|
3
|
+
end
|
4
|
+
|
5
|
+
require 'soroban/errors'
|
6
|
+
require 'soroban/helpers'
|
1
7
|
require 'soroban/parser'
|
2
8
|
|
3
9
|
module Soroban
|
@@ -7,43 +13,66 @@ module Soroban
|
|
7
13
|
# representation of its contents, and the executable Ruby version of same, as
|
8
14
|
# generated via a rewrite grammar. Cells also store their dependencies.
|
9
15
|
class Cell
|
10
|
-
attr_reader :excel, :dependencies
|
16
|
+
attr_reader :excel, :ruby, :dependencies
|
11
17
|
|
12
18
|
# Cells are initialised with a binding to allow formulas to be executed
|
13
19
|
# within the context of the sheet which owns the cell.
|
14
20
|
def initialize(context)
|
15
|
-
@dependencies =
|
16
|
-
@
|
17
|
-
@
|
18
|
-
@
|
21
|
+
@dependencies = Set.new
|
22
|
+
@excel = nil
|
23
|
+
@ruby = nil
|
24
|
+
@_binding = context
|
25
|
+
@_touched = false
|
26
|
+
@_value = nil
|
27
|
+
@_tree = nil
|
19
28
|
end
|
20
29
|
|
21
|
-
# Set the contents of a cell, and store the executable Ruby version.
|
30
|
+
# Set the contents of a cell, and store the executable Ruby version. A
|
31
|
+
# Soroban::ParseError will be raised if an attempt is made to assign a value
|
32
|
+
# that isn't recognised by the Excel parser (although in most cases this
|
33
|
+
# should be treated as if you've passed in a string value). Note that
|
34
|
+
# assigning to the cell may cause its computed value to change; call #get to
|
35
|
+
# retrieve that. Note also that #set calls #clear internally to force
|
36
|
+
# recomputation of this value on the next #get call.
|
22
37
|
def set(contents)
|
23
38
|
contents = contents.to_s
|
24
|
-
contents = "'#{contents}'" if Soroban::unknown?(contents)
|
39
|
+
contents = "'#{contents}'" if Soroban::Helpers.unknown?(contents)
|
25
40
|
clear
|
26
41
|
@excel = contents
|
27
|
-
@
|
28
|
-
raise Soroban::ParseError, Soroban::
|
29
|
-
@
|
42
|
+
@_tree = Soroban::Parser.instance.parse(@excel)
|
43
|
+
raise Soroban::ParseError, Soroban::Parser.instance.failure_reason if @_tree.nil?
|
44
|
+
@dependencies.clear
|
45
|
+
@ruby = @_tree.to_ruby(self)
|
30
46
|
end
|
31
47
|
|
32
|
-
# Clear the cached value of a cell to force it to be recalculated
|
48
|
+
# Clear the cached value of a cell to force it to be recalculated. This
|
49
|
+
# should be unnecessary to call explicitly.
|
33
50
|
def clear
|
34
|
-
@
|
51
|
+
@_value = nil
|
35
52
|
end
|
36
53
|
|
37
|
-
#
|
38
|
-
# owning sheet. Will
|
54
|
+
# Compute the value of the cell by evaluating the #ruby version of its
|
55
|
+
# contents within the context of the owning sheet. Will raise a
|
56
|
+
# Soroban::RecursionError if recursion is detected.
|
39
57
|
def get
|
40
|
-
raise Soroban::RecursionError, "Loop detected when evaluating '#{@excel}'" if @
|
41
|
-
@
|
42
|
-
@
|
58
|
+
raise Soroban::RecursionError, "Loop detected when evaluating '#{@excel}'" if @_touched
|
59
|
+
@_touched = true
|
60
|
+
@_value ||= eval(@ruby, @_binding)
|
61
|
+
@_value
|
43
62
|
rescue TypeError, RangeError, ZeroDivisionError
|
44
63
|
nil
|
45
64
|
ensure
|
46
|
-
@
|
65
|
+
@_touched = false
|
66
|
+
end
|
67
|
+
|
68
|
+
# Used by the parser to add information about which cells the value of this
|
69
|
+
# particular cell is dependent on. May pass in a single cell label or a
|
70
|
+
# collection of cells labels. The dependencies are stored as a Set, so the
|
71
|
+
# best way of adding the labels is to ensure they're converted to an
|
72
|
+
# enumerable (with [labels].flatten), and then to assign the union of the
|
73
|
+
# Set with that enumerable (with |=)
|
74
|
+
def add_dependencies(labels)
|
75
|
+
@dependencies |= [labels].flatten unless labels.nil?
|
47
76
|
end
|
48
77
|
end
|
49
78
|
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Soroban
|
2
|
+
|
3
|
+
# Raised if an invalid formula is assigned to a cell.
|
4
|
+
class ParseError < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
# Raised if calculation of a cell's formula depends on the value of the same
|
8
|
+
# cell.
|
9
|
+
class RecursionError < StandardError
|
10
|
+
end
|
11
|
+
|
12
|
+
# Raised if a referenced cell falls outside the limits of a supplied range.
|
13
|
+
class RangeError < StandardError
|
14
|
+
end
|
15
|
+
|
16
|
+
# Raised if access is attempted to an undefined cell.
|
17
|
+
class UndefinedError < StandardError
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
data/lib/soroban/functions.rb
CHANGED
@@ -1,33 +1,43 @@
|
|
1
|
+
require 'soroban/errors'
|
2
|
+
require 'soroban/helpers'
|
3
|
+
|
1
4
|
module Soroban
|
2
5
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
function_hash
|
7
|
-
|
6
|
+
class Functions
|
7
|
+
# Define one or more functions by passing in a hash mapping function name to
|
8
|
+
# the lambda that computes the function's value.
|
9
|
+
def self.define(function_hash)
|
10
|
+
@@_functions ||= {}
|
11
|
+
function_hash.each do |name, callback|
|
12
|
+
@@_functions[name.to_s.upcase.to_sym] = callback
|
13
|
+
end
|
14
|
+
end
|
8
15
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
16
|
+
# Return an array of all defined functions.
|
17
|
+
def self.all
|
18
|
+
@@_functions.keys.map(&:to_s).to_a.sort
|
19
|
+
end
|
13
20
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
21
|
+
# Call the named function within the context of the specified sheet, supplying
|
22
|
+
# some number of arguments (which is a property of the function, and therefore
|
23
|
+
# given as a splat here).
|
24
|
+
def self.call(sheet, name, *args)
|
25
|
+
callback = @@_functions[name.to_s.upcase.to_sym]
|
26
|
+
raise Soroban::UndefinedError, "No such function '#{name}'" if callback.nil?
|
27
|
+
sheet.instance_exec(*args, &callback)
|
28
|
+
end
|
19
29
|
end
|
20
30
|
|
21
31
|
end
|
22
32
|
|
33
|
+
require 'soroban/functions/and'
|
23
34
|
require 'soroban/functions/average'
|
24
|
-
require 'soroban/functions/
|
25
|
-
require 'soroban/functions/vlookup'
|
35
|
+
require 'soroban/functions/exp'
|
26
36
|
require 'soroban/functions/if'
|
27
|
-
require 'soroban/functions/
|
28
|
-
require 'soroban/functions/or'
|
29
|
-
require 'soroban/functions/not'
|
37
|
+
require 'soroban/functions/ln'
|
30
38
|
require 'soroban/functions/max'
|
31
39
|
require 'soroban/functions/min'
|
32
|
-
require 'soroban/functions/
|
33
|
-
require 'soroban/functions/
|
40
|
+
require 'soroban/functions/not'
|
41
|
+
require 'soroban/functions/or'
|
42
|
+
require 'soroban/functions/sum'
|
43
|
+
require 'soroban/functions/vlookup'
|
@@ -1,4 +1,6 @@
|
|
1
1
|
# Logical and of supplied arguments, which may be booleans, labels or ranges.
|
2
|
-
|
3
|
-
|
2
|
+
# Note that the reduce call will short-circuit as long as the |l, r| arguments
|
3
|
+
# are used in the correct order.
|
4
|
+
Soroban::Functions.define :AND => lambda { |*args|
|
5
|
+
Soroban::Helpers.getValues(binding, *args).reduce(true) { |l, r| l && r }
|
4
6
|
}
|
@@ -1,5 +1,5 @@
|
|
1
1
|
# Average the arguments, which may be numbers, labels or ranges.
|
2
|
-
Soroban::define :AVERAGE => lambda { |*args|
|
3
|
-
values = Soroban::getValues(binding, *args)
|
2
|
+
Soroban::Functions.define :AVERAGE => lambda { |*args|
|
3
|
+
values = Soroban::Helpers.getValues(binding, *args)
|
4
4
|
values.reduce(:+) / values.length.to_f
|
5
5
|
}
|
data/lib/soroban/functions/if.rb
CHANGED
data/lib/soroban/functions/ln.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
1
|
# Return the maximum of the supplied values, which may be numbers, labels or ranges.
|
2
|
-
Soroban::define :MAX => lambda { |*args|
|
3
|
-
Soroban::getValues(binding, *args).max
|
2
|
+
Soroban::Functions.define :MAX => lambda { |*args|
|
3
|
+
Soroban::Helpers.getValues(binding, *args).max
|
4
4
|
}
|
@@ -1,4 +1,4 @@
|
|
1
1
|
# Return the minimum of the supplied values, which may be numbers, labels or ranges.
|
2
|
-
Soroban::define :MIN => lambda { |*args|
|
3
|
-
Soroban::getValues(binding, *args).min
|
2
|
+
Soroban::Functions.define :MIN => lambda { |*args|
|
3
|
+
Soroban::Helpers.getValues(binding, *args).min
|
4
4
|
}
|
data/lib/soroban/functions/or.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
# Logical or of supplied arguments, which may be booleans, labels or ranges.
|
2
|
-
|
3
|
-
|
2
|
+
# Note that the reduce call will short-circuit as long as the |l, r| arguments
|
3
|
+
# are used in the correct order.
|
4
|
+
Soroban::Functions.define :OR => lambda { |*args|
|
5
|
+
Soroban::Helpers.getValues(binding, *args).reduce(false) { |l, r| l || r }
|
4
6
|
}
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# Return a value from the supplied range by searching the first column for the
|
2
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)
|
3
|
+
Soroban::Functions.define :VLOOKUP => lambda { |value, range, col, inexact|
|
4
|
+
fc, fr, tc, tr = Soroban::Helpers.getRange(range)
|
5
5
|
i = walk("#{fc}#{fr}:#{fc}#{tr}").find_index(value)
|
6
6
|
if i.nil?
|
7
7
|
nil
|
data/lib/soroban/helpers.rb
CHANGED
@@ -1,52 +1,76 @@
|
|
1
|
-
require 'soroban/
|
1
|
+
require 'soroban/errors'
|
2
|
+
require 'soroban/value_walker'
|
2
3
|
|
3
4
|
module Soroban
|
4
5
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
6
|
+
class Helpers
|
7
|
+
# Return true if the supplied data represents a formula.
|
8
|
+
def self.formula?(data)
|
9
|
+
data.to_s.slice(0..0) == '='
|
10
|
+
end
|
9
11
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
12
|
+
# Return true if the supplied data is a number.
|
13
|
+
def self.number?(data)
|
14
|
+
Float(data.to_s) && true rescue false
|
15
|
+
end
|
14
16
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
17
|
+
# Return true if the supplied data is a boolean.
|
18
|
+
def self.boolean?(data)
|
19
|
+
/^(true|false)$/i.match(data.to_s) && true ||
|
20
|
+
false
|
21
|
+
end
|
19
22
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
23
|
+
# Return true if the supplied data is a string.
|
24
|
+
def self.string?(data)
|
25
|
+
/^["](\"|[^"])*["]$/.match(data.to_s) && true ||
|
26
|
+
/^['][^']*[']$/.match(data.to_s) && true ||
|
27
|
+
false
|
28
|
+
end
|
24
29
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
30
|
+
# Return true if the supplied data is a label.
|
31
|
+
def self.label?(data)
|
32
|
+
/^([a-zA-Z]+)([\d]+)$/.match(data.to_s) && true ||
|
33
|
+
false
|
34
|
+
end
|
29
35
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
36
|
+
# Return true if the supplied data is a range.
|
37
|
+
def self.range?(data)
|
38
|
+
/^([a-zA-Z]+)([\d]+):([a-zA-Z]+)([\d]+)$/.match(data.to_s) && true ||
|
39
|
+
false
|
40
|
+
end
|
34
41
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
42
|
+
# Return true if the supplied data is of no recognised format.
|
43
|
+
def self.unknown?(data)
|
44
|
+
!formula?(data) &&
|
45
|
+
!number?(data) &&
|
46
|
+
!boolean?(data) &&
|
47
|
+
!string?(data) &&
|
48
|
+
!label?(data) &&
|
49
|
+
!range?(data)
|
50
|
+
end
|
39
51
|
|
40
|
-
|
41
|
-
|
42
|
-
#
|
43
|
-
|
44
|
-
|
45
|
-
|
52
|
+
# Return the components of a range. This converts something like "A12:C42"
|
53
|
+
# to a tuple of the form ["A", "12", "C", "42"]. Will raise a ParseError if
|
54
|
+
# the supplied argument is not a valid range.
|
55
|
+
def self.getRange(data)
|
56
|
+
raise Soroban::ParseError, "invalid #getRange for '#{data}'" if !range?(data)
|
57
|
+
/^([a-zA-Z]+)([\d]+):([a-zA-Z]+)([\d]+)$/.match(data.to_s).to_a[1..-1]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Return the row and column index of the given label. This converts something
|
61
|
+
# like "B42" into [41, 1]. It is a known bug that it does not work for labels
|
62
|
+
# of the form "BC42". Will raise a ParseError if the supplied argument is not
|
63
|
+
# a valid label.
|
64
|
+
def self.getPos(data)
|
65
|
+
raise Soroban::ParseError, "invalid #getPos for '#{data}'" if !label?(data)
|
66
|
+
match = /^([a-zA-Z]+)([\d]+)$/.match(data.to_s)
|
67
|
+
return [match[2].to_i - 1, match[1].upcase[0].ord-"A"[0].ord]
|
68
|
+
end
|
46
69
|
|
47
|
-
|
48
|
-
|
49
|
-
|
70
|
+
# Return an array of values for the supplied arguments (which may be numbers, labels and ranges).
|
71
|
+
def self.getValues(context, *args)
|
72
|
+
args.map { |arg| range?(arg) ? Soroban::ValueWalker.new(arg, context).to_a : arg }.to_a.flatten
|
73
|
+
end
|
50
74
|
end
|
51
75
|
|
52
76
|
end
|