lxl 0.2.4 → 0.3.0

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