soroban 0.7.3 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,9 +5,10 @@ notifications:
5
5
  rvm:
6
6
  - ree
7
7
  - 1.8.7
8
+ - jruby-18mode
8
9
  - 1.9.2
9
10
  - 1.9.3
10
- - jruby-18mode
11
11
  - jruby-19mode
12
- - rbx-18mode
13
- - rbx-19mode
12
+ - 2.0.0
13
+ - 2.1.0
14
+ - 2.1.1
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 # => [:E1, :D1, :D2, :D3, :D4, :D5]
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 # => ["AVERAGE", "SUM", "VLOOKUP", "IF", "AND", "OR", "NOT", "MAX", "MIN", "LN", "EXP"]
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
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = "soroban"
8
- s.version = "0.7.3"
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-20"
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/error.rb",
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.7.3
1
+ 0.8.0
@@ -1,4 +1,3 @@
1
+ require 'soroban/parser'
1
2
  require 'soroban/sheet'
2
- require 'soroban/cell'
3
- require 'soroban/error'
4
3
  require 'soroban/import'
@@ -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
- @binding = context
17
- @touched = false
18
- @value = nil
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
- @tree = Soroban::parser.parse(@excel)
28
- raise Soroban::ParseError, Soroban::parser.failure_reason if @tree.nil?
29
- @ruby = @tree.to_ruby(@dependencies.clear)
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
- @value = nil
51
+ @_value = nil
35
52
  end
36
53
 
37
- # Eval the Ruby version of the string contents within the context of the
38
- # owning sheet. Will throw Soroban::RecursionError if recursion is detected.
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 @touched
41
- @touched = true
42
- @value ||= eval(@ruby, @binding)
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
- @touched = false
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
@@ -1,33 +1,43 @@
1
+ require 'soroban/errors'
2
+ require 'soroban/helpers'
3
+
1
4
  module Soroban
2
5
 
3
- # Define a new function.
4
- def self.define(function_hash)
5
- @@functions ||= {}
6
- function_hash.each { |name, callback| @@functions[name] = callback }
7
- end
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
- # Return an array of all defined functions.
10
- def self.functions
11
- @@functions.keys.map { |f| f.to_s }
12
- end
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
- # 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])
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/sum'
25
- require 'soroban/functions/vlookup'
35
+ require 'soroban/functions/exp'
26
36
  require 'soroban/functions/if'
27
- require 'soroban/functions/and'
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/ln'
33
- require 'soroban/functions/exp'
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
- Soroban::define :AND => lambda { |*args|
3
- Soroban::getValues(binding, *args).reduce(true) { |s, a| s && a }
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
  }
@@ -1,4 +1,2 @@
1
1
  # Return e raised to the power of the argument
2
- Soroban::define :EXP => lambda { |val|
3
- Math.exp(val)
4
- }
2
+ Soroban::Functions.define :EXP => lambda { |val| Math.exp(val) }
@@ -1,4 +1,4 @@
1
1
  # Return one of two values depending on the value of the supplied boolean.
2
- Soroban::define :IF => lambda { |val, if_true, if_false|
2
+ Soroban::Functions.define :IF => lambda { |val, if_true, if_false|
3
3
  val ? if_true : if_false
4
4
  }
@@ -1,4 +1,2 @@
1
1
  # Return the natural logarithm of the argument
2
- Soroban::define :LN => lambda { |val|
3
- Math.log(val)
4
- }
2
+ Soroban::Functions.define :LN => lambda { |val| Math.log(val) }
@@ -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
  }
@@ -1,4 +1,2 @@
1
1
  # Return the logical not of the supplied boolean.
2
- Soroban::define :NOT => lambda { |val|
3
- !val
4
- }
2
+ Soroban::Functions.define :NOT => lambda { |val| !val }
@@ -1,4 +1,6 @@
1
1
  # Logical or of supplied arguments, which may be booleans, labels or ranges.
2
- Soroban::define :OR => lambda { |*args|
3
- Soroban::getValues(binding, *args).reduce(false) { |s, a| s || a }
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,4 +1,4 @@
1
1
  # Sum the arguments, which may be numbers, labels or ranges.
2
- Soroban::define :SUM => lambda { |*args|
3
- Soroban::getValues(binding, *args).reduce(:+)
2
+ Soroban::Functions.define :SUM => lambda { |*args|
3
+ Soroban::Helpers.getValues(binding, *args).reduce(:+)
4
4
  }
@@ -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
@@ -1,52 +1,76 @@
1
- require 'soroban/parser'
1
+ require 'soroban/errors'
2
+ require 'soroban/value_walker'
2
3
 
3
4
  module Soroban
4
5
 
5
- # Return true if the supplied data represents a formula.
6
- def self.formula?(data)
7
- data.to_s.slice(0..0) == '='
8
- end
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
- # Return true if the supplied data is a number.
11
- def self.number?(data)
12
- Float(data.to_s) && true rescue false
13
- end
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
- # 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
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
- # 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
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
- # 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
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
- # 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
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
- # 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
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
- # Return the row and column index of the given label.
41
- def self.getPos(label)
42
- # TODO: fix for labels such as "BC42"
43
- match = /^([a-zA-Z]+)([\d]+)$/.match(label.to_s)
44
- return match[2].to_i - 1, match[1].upcase[0].ord-"A"[0].ord
45
- end
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
- # Return an array of values for the supplied arguments (which may be numbers, labels and ranges).
48
- def self.getValues(context, *args)
49
- args.map { |arg| Soroban::range?(arg) ? ValueWalker.new(arg, context).to_a : arg }.to_a.flatten
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