lxl 0.2.4 → 0.3.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.
Files changed (8) hide show
  1. data/CHANGES +13 -0
  2. data/README.en +68 -0
  3. data/VERSION +1 -1
  4. data/lib/lxl.rb +250 -128
  5. data/setup.rb +1331 -0
  6. data/test/lxl_test.rb +84 -18
  7. metadata +4 -3
  8. data/README +0 -48
data/CHANGES CHANGED
@@ -1,3 +1,16 @@
1
+ 0.3.0
2
+
3
+ - Functions and constants moved into LXL::Namespace (major refactoring)
4
+ - Added ranges (r): A1:D5 => LXL::Range.new("A1","D5")
5
+ LXL::Range is a Range subclass which overrides #each to return an excel-style range
6
+ - Added percentages (p): 25% => 0.25
7
+ - Added & string concentation operator
8
+ - Added ^ exponential operator
9
+ - Added semi-colon guessing: eliminates the need for statement separator in most cases (\n= becomes \n;)
10
+ - Fix: semi-colons parsed as tokens again to allow semis in strings
11
+ - Refactored string quoting
12
+ - Filled out rdoc
13
+
1
14
  0.2.4
2
15
 
3
16
  - Added Deferred function calls via register_deferred
@@ -0,0 +1,68 @@
1
+ LXL README
2
+ ============
3
+
4
+ LXL (Like Excel) is a mini-language that mimics Microsoft Excel formulas.
5
+ Easily extended with new constants and functions.
6
+
7
+ Usage
8
+ -----
9
+
10
+ formulas = %{
11
+ This is some text;
12
+ ="This is some ""quoted"" text"
13
+ =((1+2)*(10-6))/2
14
+ =datetime("2004-11-22 11:11:00")=DATE(2004,11,22)+TIME(11,11,00)
15
+ =IN(" is ", "this is a string")
16
+ =LIST(1, "two", 3.0)
17
+ =IN("b", LIST("a", "b", "c"))
18
+ =AND(TRUE, NULL)
19
+ =OR(TRUE, FALSE)
20
+ =IF(1+1=2, "yes", "no")
21
+ ="this" & " and " & "that"
22
+ =2^3
23
+ =25%+25.2%
24
+ }
25
+
26
+ # single formula
27
+ puts LXL.eval('=5+5').inspect # => 10
28
+
29
+ # multiple formulas separated by semi-colon
30
+ puts LXL.eval(formulas).inspect
31
+ # => ["This is some text", "This is some \"quoted\" text", 6, true, true, [1, "two", 3.0], true, false, true, "yes", "this and that", 8, 0.502]
32
+
33
+ See API docs for more information.
34
+
35
+ Requirements
36
+ ------------
37
+
38
+ * Ruby 1.8.2
39
+
40
+ Install
41
+ -------
42
+
43
+ GEM file
44
+
45
+ gem install lxl (remote)
46
+ gem install lxl-x.x.x.gem (local)
47
+
48
+ Tarball (setup.rb)
49
+
50
+ De-Compress archive and enter its top directory.
51
+ Then type:
52
+
53
+ $ ruby setup.rb config
54
+ $ ruby setup.rb setup
55
+ ($ su)
56
+ # ruby setup.rb install
57
+
58
+ You can also install files into your favorite directory
59
+ by supplying setup.rb some options. Try "ruby setup.rb --help".
60
+
61
+ License
62
+ -------
63
+
64
+ LXL incorporates John Carter's LittleLexer for parsing.
65
+ Distributes under the same terms as LittleLexer.
66
+ http://www.rubyforge.org/projects/littlelexer/
67
+
68
+ Kevin Howe <kh@newclear.ca>
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.4
1
+ 0.3.0
data/lib/lxl.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # LXL (Like Excel) is a mini-language that mimics Microsoft Excel formulas.
2
- # Easily extended with new constants and functions.
2
+ # Easily extended with new constants and functions using LXL::Namespace.
3
3
  #
4
4
  # =Constants
5
5
  #
@@ -19,6 +19,8 @@
19
19
  # a > b # Greater than
20
20
  # a <= b # Less than or equal to
21
21
  # a >= b # Greater than or equal to
22
+ # a & b # String concentation
23
+ # n ^ n # Exponential
22
24
  #
23
25
  # =Logical Functions
24
26
  #
@@ -42,13 +44,47 @@
42
44
  # =Notes
43
45
  #
44
46
  # * The number zero is interpereted as FALSE
47
+ # * Percentages are translated: 25% => 0.25
48
+ # * Ranges are translated: A1:D5 => LXL::Range.new("A1", "D5")
45
49
  # * Return multiple results in an array by separating formulas with a semi-colon (;)
50
+ # * Constant and Function names are case-insensitive
51
+ # * Values prefixed with = are formulas, anything else is assumed to be text:
46
52
  #
53
+ # LXL.eval("5+5") # => '5+5'
54
+ # LXL.eval("=5+5") # => 10
55
+ #
56
+ module LXL
57
+
58
+ module_function
59
+
60
+ # False if nil, false or zero.
61
+ #
62
+ def to_b(value)
63
+ [nil,false,0].include?(value) ? false : true
64
+ end
65
+
66
+ # Evaluate a formula.
67
+ #
68
+ def eval(formula)
69
+ LXL::Parser.eval(formula)
70
+ end
71
+
72
+ # Create a new Parser.
73
+ #
74
+ def new(*args)
75
+ LXL::Parser.new(*args)
76
+ end
77
+
78
+ end
79
+
47
80
  # =Lexical Types
48
81
  #
49
- # w Whitespace (includes Commas)
82
+ # w Whitespace (includes commas)
83
+ # ; Semi-Colon (statement separator)
50
84
  # o Operator
51
85
  # s String
86
+ # r Range
87
+ # p Percentage
52
88
  # f Float
53
89
  # i Integer
54
90
  # t Token
@@ -58,146 +94,95 @@
58
94
  # F Function
59
95
  # C Constant
60
96
  #
61
- module LXL
62
-
63
- module_function
64
-
65
- # Evaluate a formula
66
- #
67
- def eval(formula)
68
- LXL::Parser.eval(formula)
69
- end
70
-
71
- end
72
-
73
97
  class LXL::Parser
74
98
 
75
- attr_reader :constants, :functions, :lexer
99
+ attr_reader :namespace, :lexer
76
100
 
77
- RUBY_OPERATORS = ['+', '-', '*', '/', '<=', '>=', '==', '!=', '<', '>']
78
- EXCEL_OPERATORS = ['+', '-', '*', '/', '<=', '>=', '=', '<>', '<', '>']
101
+ RUBY_OPERATORS = ['+', '-', '*', '/', '<=', '>=', '==', '!=', '<', '>', '+', '**']
102
+ EXCEL_OPERATORS = ['+', '-', '*', '/', '<=', '>=', '=', '<>', '<', '>', '&', '^' ]
79
103
 
80
- # Evaluate a formula
104
+ # Evaluate a formula.
81
105
  #
82
106
  def self.eval(formula)
83
107
  self.new.eval(formula)
84
108
  end
85
-
86
- # Initialize parser
109
+
110
+ # Default namespace class.
87
111
  #
88
- def initialize
89
-
90
- # Constants
91
- @constants = Hash.new
92
- register_constant(:TRUE, true)
93
- register_constant(:FALSE, false)
94
- register_constant(:NULL, nil)
95
-
96
- # Functions
97
- @functions = Hash.new
98
- register_function(:AND) { |a,b| to_b(a) && to_b(b) }
99
- register_function(:OR) { |a,b| to_b(a) || to_b(b) }
100
- register_function(:IF) { |v,a,b| to_b(v) ? a : b }
101
- register_function(:LIST) { |*args| args }
102
- register_function(:IN) { |n,h| h.respond_to?(:include?) ? h.include?(n) : false }
103
- register_function(:TODAY) { Date.today.ajd.to_f }
104
- register_function(:NOW) { DateTime.now.ajd.to_f }
105
- register_function(:DATE) { |y,m,d| Date.new(y,m,d).ajd.to_f }
106
- register_function(:DATETIME) { |value| DateTime.parse(value).ajd.to_f }
107
- register_function(:TIME) { |h,m,s|
108
- DateTime.valid_time?(h,m,s) ? DateTime.valid_time?(h,m,s).to_f : raise(ArgumentError, 'invalid time')
109
- }
112
+ def self.namespace_class
113
+ LXL::Namespace
114
+ end
110
115
 
111
- # Lexer
116
+ # Initialize namespace and parser.
117
+ #
118
+ def initialize(namespace=self.class.namespace_class.new)
119
+ @namespace = namespace
112
120
  ops = EXCEL_OPERATORS.collect { |v| Regexp.quote(v) }.join('|')
113
- #
114
121
  @lexer = LXL::LittleLexer.new([
115
122
  [/\A[\s,]+/, ?w], # Whitespace (includes Commas)
123
+ [/\A;/, ?;], # Semi-Colon (statement separator)
116
124
  [/\A(#{ops})/, ?o], # Operator
117
125
  [/\A("([^"]|"")*")/m, ?s], # String
118
- [/\A[0-9]+\.[0-9]+/, ?f], # Float
119
- [/\A[0-9]+/, ?i], # Integer
126
+ [/\A\w+:\w+/, ?r], # Range
127
+ [/\A\d+(\.\d+)?%/, ?p], # Percentage
128
+ [/\A\d+\.\d+/, ?f], # Float
129
+ [/\A\d+/, ?i], # Integer
120
130
  [/\A\w+/, ?t], # Token
121
131
  [/\A\(/, ?(], # Open (
122
132
  [/\A\)/, ?)], # Close )
123
133
  ], false)
124
134
  end
125
135
 
126
- # Evaluate formula
136
+ # Evaluate a formula.
127
137
  #
128
138
  def eval(formula)
129
- formulas = formula.to_s.split(';').collect { |f| f.strip }.find_all { |f| ! f.empty? }
130
- formulas.collect! { |f| Kernel.eval(tokenize_text(f).last.join, binding) }
139
+ formulas = [[]]
140
+ formula = formula.to_s.gsub(/(\n)(\s*=)/, '\1;\2') # infer statement separators
141
+ types,tokens = tokenize(formula)
142
+ tokens.each_index { |i| tokens[i] == ';' ? formulas << [] : formulas.last << [types[i], tokens[i]] }
143
+ formulas.collect! { |f|
144
+ types = f.collect { |t| t.first }
145
+ tokens = f.collect { |t| t.last }
146
+ token = tokens.join.strip
147
+ if token =~ /^=/
148
+ tokens.each_index { |i| tokens[i] = translate_quotes(tokens[i]) if types[i] == :s }
149
+ token = tokens.join.strip.gsub(/^=+/, '')
150
+ else
151
+ token = translate_quotes(quote(tokens.join.strip))
152
+ end
153
+ }
154
+ formulas.delete_if { |f| f == '""' }
155
+ formulas.collect! { |f| Kernel.eval(f, binding) }
131
156
  formulas.size == 1 ? formulas.first : formulas
132
157
  end
133
158
 
134
- # Register a function
135
- #
136
- # * Converts name to symbol
137
- # * Wraps function with a debugging procedure
138
- #
139
- def register_function(name, &block)
140
- name = name(name)
141
- @functions[name] = debug(name, &block)
142
- end
143
-
144
- # Register a constant
145
- #
146
- # * Converts name to symbol
147
- #
148
- def register_constant(name, value)
149
- name = name(name)
150
- @constants[name] = value
151
- end
152
-
153
- # Register a constant for each symbol of the same name and value
154
- #
155
- def register_symbols(*symbols)
156
- symbols.each { |s| register_constant(s, s) }
157
- end
158
-
159
- # Register each symbol as a Deferred function call
160
- #
161
- def register_deferred(*symbols)
162
- symbols.each { |s| register_function(s) { |*args| LXL::Deferred.new(s, *args) } }
163
- end
164
-
165
159
  protected
166
160
 
167
- # Translate to uppercase symbol
168
- #
169
- def name(obj)
170
- obj.to_s.upcase.to_sym
171
- end
172
-
173
- # Wrap a procedure in a debugging procedure
161
+ # Quote a text value.
174
162
  #
175
- # * Raise an error unless given the correct number of arguments
163
+ # quote('a "quoted" value.')
164
+ # # => '"a ""quoted"" value."'
176
165
  #
177
- def debug(name, &block)
178
- raise ArgumentError, 'block not given to debug' unless block_given?
179
- Proc.new { |*args|
180
- if block.arity >= 0 and block.arity != args.size
181
- raise ArgumentError, "wrong number of arguments (#{args.size} for #{block.arity}) for #{name}"
182
- end
183
- block.call(*args)
184
- }
166
+ def quote(text)
167
+ '"'+text.to_s.gsub('"','""')+'"'
185
168
  end
186
169
 
187
- # Translates text to a formula before tokenizing
170
+ # Translate "" to \" in quoted string values.
188
171
  #
189
- def tokenize_text(text)
190
- formula = (text =~ /^=/) ? text : '="'+text.gsub('"','""')+'"'
191
- tokenize(formula)
172
+ # translate_quotes('"a ""quoted"" value."')
173
+ # # => '"a \"quoted\" value."'
174
+ #
175
+ def translate_quotes(text)
176
+ text.to_s.gsub(/([^\\])""/,'\1\"')
192
177
  end
193
178
 
194
- # Tokenize formula into [TypesArray, TokensArray]
179
+ # Tokenize a formula into <tt>[TypesArray, TokensArray]</tt>.
195
180
  #
196
181
  def tokenize(formula)
197
182
  ops = Hash[*EXCEL_OPERATORS.zip(RUBY_OPERATORS).flatten]
198
183
 
199
184
  # Parse formula
200
- types,tokens = @lexer.scan(formula.gsub(/^=/,''))
185
+ types,tokens = @lexer.scan(formula.to_s)
201
186
  raise SyntaxError, 'unbalanced parentheses' unless balanced?(tokens)
202
187
 
203
188
  # Parse tokens
@@ -205,23 +190,25 @@ class LXL::Parser
205
190
  type, token = types[i], tokens[i]
206
191
  tokens[i] = case type
207
192
  when :o then ops[token]
208
- when :s then token.gsub(/([^\\])""/,'\1\"') # "" to \"
193
+ when :r then "LXL::Range.new(*#{token.split(':').inspect})"
194
+ when :p then token.to_f/100
209
195
  when :f then token.to_f
210
196
  when :i then token.to_i
211
197
  when :t then
212
- name = name(token)
213
- if @functions.key?(name)
198
+ upper = token.to_s.upcase.to_sym
199
+ lower = token.to_s.downcase.to_sym
200
+ raise NoMethodError, "protected method `#{token}` called for #{self}" if @namespace.const_get(:METHODS).include?(token.to_s)
201
+ if @namespace.respond_to?(lower)
214
202
  if tokens[i+1] != '('
215
203
  raise ArgumentError, "wrong number of arguments for #{token}"
216
204
  else
217
205
  types[i] = :F
218
- "@functions[:#{name}].call"
206
+ "@namespace.method(:#{lower}).call"
219
207
  end
220
- elsif @constants.key?(name)
208
+ elsif @namespace.const_defined?(upper)
221
209
  types[i] = :C
222
- "@constants[:#{name}]"
223
- else
224
- raise NameError, "unknown constant #{token}"
210
+ "@namespace.const_get(:#{upper})"
211
+ else token
225
212
  end
226
213
  else token
227
214
  end
@@ -230,21 +217,47 @@ class LXL::Parser
230
217
  [types,tokens]
231
218
  end
232
219
 
233
- # Check that parentheses are balanced
220
+ # Check that parentheses are balanced.
234
221
  #
235
222
  def balanced?(tokens)
236
223
  tokens.find_all { |t| ['(', ')'].include?(t) }.size % 2 == 0
237
224
  end
238
225
 
239
- # False if nil, false or zero
240
- #
241
- def to_b(value)
242
- [nil,false,0].include?(value) ? false : true
226
+ end
227
+
228
+ # Excel-style ranges.
229
+ #
230
+ # Range.new("B3", "D5").collect
231
+ # # => ["B3", "B4", "B5", "B6", "B7", "B8", "B9",
232
+ # "C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9",
233
+ # "D0", "D1", "D2", "D3", "D4", "D5"]
234
+ #
235
+ # LXL::Range.new("B3", "D5").collect
236
+ # # => ["B3", "B4", "B5", "C3", "C4", "C5", "D3", "D4", "D5"]
237
+ #
238
+ class LXL::Range < Range
239
+
240
+ def each
241
+ a,b = self.begin.to_s, self.end.to_s
242
+ if a =~ /\D/ and b =~ /\D/
243
+ r = [a,b].collect{ |x| x.upcase }.sort
244
+ a_col,b_col = r.collect { |x| x.gsub(/\d/, '') }
245
+ a_cel,b_cel = r.collect { |x| x.gsub(/\D/, '') }
246
+ range = Range.new(a_col,b_col).each{ |col| Range.new(a_cel,b_cel).each { |cel| yield col+cel } }
247
+ else
248
+ super
249
+ end
243
250
  end
244
251
 
245
252
  end
246
253
 
247
- # Deferred function call (snapshots the symbol and arguments).
254
+ # Snapshots the symbol/arguments of a function call for later use.
255
+ #
256
+ # class MyNamespace < LXL::Namespace
257
+ # register_deferred :foo
258
+ # end
259
+ # LXL.new(MyNamespace.new).eval('=FOO(1, "two", 3)').inspect
260
+ # # => <LXL::Deferred @args=[1, "two", 3] @symbol=:foo>
248
261
  #
249
262
  class LXL::Deferred
250
263
 
@@ -257,7 +270,112 @@ class LXL::Deferred
257
270
 
258
271
  end
259
272
 
260
- # John Carter's LittleLexer
273
+ # Namespace lookup for constants and functions (minimal methods).
274
+ #
275
+ class LXL::EmptyNamespace
276
+
277
+ # Remove all unessecary methods
278
+ ['constants', 'const_defined?', 'const_get', 'const_set'].each { |m| define_method(m) { |s| self.class.send(m,s) } }
279
+ METHODS = ['__id__', '__send__', 'constants', 'const_defined?', 'const_get', 'const_set', 'class', 'instance_methods', 'method', 'methods', 'respond_to?', 'to_s']
280
+ (instance_methods-METHODS).sort.each { |m| undef_method m }
281
+
282
+ # Ensure all constants are included (inherited, extended, included, etc).
283
+ #
284
+ def self.const_defined?(symbol) #:nodoc:
285
+ constants.include?(symbol.to_s)
286
+ end
287
+
288
+ # Register lowercase methods which return a Deferred function call.
289
+ #
290
+ # class MyNamespace < LXL::Namespace
291
+ # register_deferred :foo
292
+ # end
293
+ # LXL.new(MyNamespace.new).eval('=FOO(1, "two", 3)').inspect
294
+ # # => <LXL::Deferred @args=[1, "two", 3] @symbol=:foo>
295
+ #
296
+ def self.register_deferred(*symbols)
297
+ symbols.collect! { |s| s.to_s.downcase.to_sym }
298
+ symbols.each { |s| define_method(s) { |*args| LXL::Deferred.new(s, *args) } }
299
+ end
300
+
301
+ # Register uppercase constants of the same name and value.
302
+ #
303
+ # class MyNamespace < LXL::Namespace
304
+ # register_symbols :foo, :bar
305
+ # end
306
+ # LXL.new(MyNamespace.new).eval('=LIST(FOO, BAR)').inspect
307
+ # # => [:FOO, :BAR]
308
+ #
309
+ def self.register_symbols(*symbols)
310
+ symbols.collect! { |s| s.to_s.upcase.to_sym }
311
+ symbols.each { |s| const_set(s, s) }
312
+ end
313
+
314
+ end
315
+
316
+ # Constants and functions defined here will become available to formulas.
317
+ #
318
+ # class MyLXLNamespace < LXL::Namespace
319
+ # NAME = 'John Doe'
320
+ # def upper(text) text.to_s.upcase end
321
+ # end
322
+ #
323
+ # class MyLXL < LXL::Parser
324
+ # def self.namespace_class() MyLXLNamespace end
325
+ # end
326
+ #
327
+ # MyLXL.eval('=UPPER(NAME)')
328
+ # # => JOHN DOE
329
+ #
330
+ class LXL::Namespace < LXL::EmptyNamespace
331
+
332
+ TRUE = true
333
+ FALSE = false
334
+ NULL = nil
335
+
336
+ def and(a,b)
337
+ LXL.to_b(a) && LXL.to_b(b)
338
+ end
339
+
340
+ def or(a,b)
341
+ LXL.to_b(a) || LXL.to_b(b)
342
+ end
343
+
344
+ def if(c,a,b)
345
+ LXL.to_b(c) ? a : b
346
+ end
347
+
348
+ def list(*items)
349
+ items
350
+ end
351
+
352
+ def in(n,h)
353
+ h.respond_to?(:include?) ? h.include?(n) : false
354
+ end
355
+
356
+ def today
357
+ Date.today.ajd.to_f
358
+ end
359
+
360
+ def now
361
+ DateTime.now.ajd.to_f
362
+ end
363
+
364
+ def date(y,m,d)
365
+ Date.new(y,m,d).ajd.to_f
366
+ end
367
+
368
+ def datetime(text)
369
+ DateTime.parse(text.to_s).ajd.to_f
370
+ end
371
+
372
+ def time(h,m,s)
373
+ DateTime.valid_time?(h,m,s) ? DateTime.valid_time?(h,m,s).to_f : raise(ArgumentError, 'invalid time')
374
+ end
375
+
376
+ end
377
+
378
+ # John Carter's LittleLexer.
261
379
  #
262
380
  # http://www.rubyforge.org/projects/littlelexer
263
381
  #
@@ -321,21 +439,25 @@ if $0 == __FILE__
321
439
 
322
440
  formulas = %{
323
441
  This is some text;
324
- ="This is some ""quoted"" text";
325
- =((1+2)*(10-6))/2;
326
- =datetime("2004-11-22 11:11:00")=DATE(2004,11,22)+TIME(11,11,00);
327
- =IN(" is ", "this is a string");
328
- =LIST(1, "two", 3.0);
329
- =IN("b", LIST("a", "b", "c"));
330
- =AND(TRUE, NULL);
331
- =OR(TRUE, FALSE);
332
- =IF(1+1=2, "yes", "no");
442
+ ="This is some ""quoted"" text"
443
+ =((1+2)*(10-6))/2
444
+ =datetime("2004-11-22 11:11:00")=DATE(2004,11,22)+TIME(11,11,00)
445
+ =IN(" is ", "this is a string")
446
+ =LIST(1, "two", 3.0)
447
+ =IN("b", LIST("a", "b", "c"))
448
+ =AND(TRUE, NULL)
449
+ =OR(TRUE, FALSE)
450
+ =IF(1+1=2, "yes", "no")
451
+ ="this" & " and " & "that"
452
+ =2^3
453
+ =25%+25.2%
333
454
  }
334
-
455
+
335
456
  # single formula
336
457
  puts LXL.eval('=5+5').inspect # => 10
337
458
 
338
459
  # multiple formulas separated by semi-colon
339
- puts LXL.eval(formulas).inspect # => ["This is some text", "This is some \"quoted\" text", 6, true, true, [1, "two", 3.0], true, false, true, "yes"]
460
+ puts LXL.eval(formulas).inspect
461
+ # => ["This is some text", "This is some \"quoted\" text", 6, true, true, [1, "two", 3.0], true, false, true, "yes", "this and that", 8, 0.502]
340
462
 
341
463
  end