eggshell 0.8.3 → 1.0.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.
@@ -0,0 +1,707 @@
1
+ module Eggshell
2
+ class Processor
3
+ BLOCK_MATCH = /^([a-z0-9_-]+\.)/
4
+ BLOCK_MATCH_PARAMS = /^([a-z0-9_-]+)\(/
5
+
6
+ def initialize
7
+ @context = Eggshell::ProcessorContext.new
8
+ @vars = @context.vars
9
+ @funcs = @context.funcs
10
+ @macros = @context.macros
11
+ @blocks = @context.blocks
12
+ @blocks_map = @context.blocks_map
13
+ @block_params = @context.block_params
14
+ @expr_cache = @context.expr_cache
15
+ @fmt_handlers = @context.fmt_handlers
16
+ @ee = Eggshell::ExpressionEvaluator.new(@vars, @funcs)
17
+
18
+ @noop_macro = Eggshell::MacroHandler::Defaults::NoOpHandler.new
19
+ @noop_block = Eggshell::BlockHandler::Defaults::NoOpHandler.new
20
+ end
21
+
22
+ attr_reader :context
23
+
24
+ def add_block_handler(handler, *names)
25
+ _trace "add_block_handler: #{names.inspect} -> #{handler.class}"
26
+ @blocks << handler
27
+ names.each do |name|
28
+ @blocks_map[name] = handler
29
+ end
30
+ end
31
+
32
+ def rem_block_handler(*names)
33
+ _trace "rem_block_handler: #{names.inspect}"
34
+ names.each do |name|
35
+ handler = @blocks_map.delete(name)
36
+ @blocks.delete(handler)
37
+ end
38
+ end
39
+
40
+ def add_macro_handler(handler, *names)
41
+ _trace "add_macro_handler: #{names.inspect} -> #{handler.class}"
42
+ names.each do |name|
43
+ @macros[name] = handler
44
+ end
45
+ end
46
+
47
+ def rem_macro_handler(*names)
48
+ _trace "rem_macro_handler: #{names.inspect}"
49
+ names.each do |name|
50
+ @macros.delete(name)
51
+ end
52
+ end
53
+
54
+ # Registers a function for embedded expressions. Functions are grouped into namespaces,
55
+ # and a handler can be assigned to handle all function calls within that namespace, or
56
+ # a specific set of functions within the namespace. The root namespace is a blank string.
57
+ #
58
+ # @param String func_key In the form `ns` or `ns:func_name`. For functions in the
59
+ # root namespace, do `:func_name`.
60
+ # @param Object handler
61
+ # @param Array func_names If `func_key` only refers to a namespace but the handler
62
+ # needs to only handle a subset of functions, supply the list of function names here.
63
+ def register_functions(func_key, handler, func_names = nil)
64
+ @ee.register_functions(func_key, handler, func_names)
65
+ end
66
+
67
+ def _error(msg)
68
+ $stderr.write("[ERROR] #{msg}\n")
69
+ end
70
+
71
+ def _warn(msg)
72
+ $stderr.write("[WARN] #{msg}\n")
73
+ end
74
+
75
+ def _info(msg)
76
+ return if @vars['log.level'] < 1
77
+ $stderr.write("[INFO] #{msg}\n")
78
+ end
79
+
80
+ def _debug(msg)
81
+ return if @vars['log.level'] < 2
82
+ $stderr.write("[DEBUG] #{msg}\n")
83
+ end
84
+
85
+ def _trace(msg)
86
+ return if @vars['log.level'] < 3
87
+ $stderr.write("[TRACE] #{msg}\n")
88
+ end
89
+
90
+ attr_reader :vars
91
+
92
+ def expr_eval(struct)
93
+ return Eggshell::ExpressionEvaluator.expr_eval(struct, @vars, @funcs)
94
+ end
95
+
96
+ # Expands expressions (`\${}`) and macro calls (`\@@macro\@@`).
97
+ # @todo deprecate @@macro@@?
98
+ def expand_expr(expr)
99
+ # replace dynamic placeholders
100
+ # @todo expand to actual expressions
101
+ buff = []
102
+ esc = false
103
+ exp = false
104
+ mac = false
105
+
106
+ toks = expr.split(/(\\|\$\{|\}|@@|"|')/)
107
+ i = 0
108
+
109
+ plain_str = ''
110
+ expr_str = ''
111
+ quote = nil
112
+ expr_delim = nil
113
+
114
+ while i < toks.length
115
+ tok = toks[i]
116
+ i += 1
117
+ next if tok == ''
118
+
119
+ if esc
120
+ plain_str += '\\' + tok
121
+ esc = false
122
+ next
123
+ end
124
+
125
+ if exp
126
+ if quote
127
+ expr_str += tok
128
+ if tok == quote
129
+ quote = nil
130
+ end
131
+ elsif tok == '"' || tok == "'"
132
+ expr_str += tok
133
+ quote = tok
134
+ elsif tok == expr_delim
135
+ struct = @expr_cache[expr_str]
136
+
137
+ if !struct
138
+ struct = Eggshell::ExpressionEvaluator.struct(expr_str)
139
+ @expr_cache[expr_str] = struct
140
+ end
141
+
142
+ if !mac
143
+ buff << expr_eval(struct)
144
+ else
145
+ args = struct[0]
146
+ macro = args[1]
147
+ args = args[2] || []
148
+ macro_handler = @macros[macro]
149
+ if macro_handler
150
+ macro_handler.process(buff, macro, args, nil, -1)
151
+ else
152
+ _warn("macro (inline) not found: #{macro}")
153
+ end
154
+ end
155
+
156
+ exp = false
157
+ mac = false
158
+ expr_delim = nil
159
+ expr_str = ''
160
+ else
161
+ expr_str += tok
162
+ end
163
+ # only unescape if not in expression, since expression needs to be given as-is
164
+ elsif tok == '\\'
165
+ esc = true
166
+ next
167
+ elsif tok == '${' || tok == '@@'
168
+ if plain_str != ''
169
+ buff << plain_str
170
+ plain_str = ''
171
+ end
172
+ exp = true
173
+ expr_delim = '}'
174
+ if tok == '@@'
175
+ mac = true
176
+ expr_delim = tok
177
+ end
178
+ else
179
+ plain_str += tok
180
+ end
181
+ end
182
+
183
+ # if exp -- throw exception?
184
+ buff << plain_str if plain_str != ''
185
+ return buff.join('')
186
+ end
187
+
188
+ TAB = "\t"
189
+ TAB_SPACE = ' '
190
+
191
+ # @param Boolean is_default If true, associates these parameters with the
192
+ # `block_type` used in `get_block_param()` or explicitly in third parameter.
193
+ # @param String block_type
194
+ def set_block_params(params, is_default = false, block_type = nil)
195
+ if block_type && is_default
196
+ @block_params[block_type] = params
197
+ else
198
+ @block_params[:pending] = params
199
+ @block_param_default = is_default
200
+ end
201
+ end
202
+
203
+ # Gets the block parameters for a block type, and merges default values if available.
204
+ def get_block_params(block_type)
205
+ bp = @block_params.delete(:pending)
206
+ if @block_params_default
207
+ if block_type && bp
208
+ @block_params[block_type] = bp if bp
209
+ end
210
+ @block_params_default = false
211
+ bp = {} if !bp
212
+ else
213
+ bp = {} if !bp
214
+ default = @block_params[block_type]
215
+ if default
216
+ default.each do |key,val|
217
+ if !bp.has_key?(key) && val
218
+ bp[key] = val.clone
219
+ end
220
+ end
221
+ end
222
+ end
223
+ return bp
224
+ end
225
+
226
+ # Sets the default output object. Must support {{<<}} and {{join(String)}}.
227
+ #
228
+ # If {{out}} is a `Class`, must support empty initialization.
229
+ def set_out(out)
230
+ @out = out
231
+ end
232
+
233
+ def get_out
234
+ if !@out
235
+ []
236
+ elsif @out.is_a?(Class)
237
+ @out.new
238
+ else
239
+ @out
240
+ end
241
+ end
242
+
243
+ BH = Eggshell::BlockHandler
244
+
245
+ def preprocess(lines, line_count = 0)
246
+ line_start = line_count
247
+ line_buff = nil
248
+ indent = 0
249
+ mode = nil
250
+
251
+ in_html = false
252
+ end_html = nil
253
+
254
+ parse_tree = Eggshell::ParseTree.new
255
+
256
+ """
257
+ algorithm for normalizing lines:
258
+
259
+ - skip comments (process directive if present)
260
+ - if line is continuation, set current line = last + current
261
+ - if line ends in \ and is not blank otherwise, set new continuation and move to next line
262
+ - if line ends in \ and is effectively blank, append '\n'
263
+ - calculate indent level
264
+ """
265
+ i = 0
266
+ begin
267
+ while i < lines.length
268
+ oline = lines[i]
269
+ i += 1
270
+
271
+ line_count += 1
272
+
273
+ hdr = oline.lstrip[0..1]
274
+ if hdr == '#!'
275
+ next
276
+ end
277
+
278
+ line = oline.chomp
279
+ line_end = oline[line.length..-1]
280
+ if line_buff
281
+ line_buff += line
282
+ line = line_buff
283
+ line_buff = nil
284
+ else
285
+ line_start += 1
286
+ end
287
+
288
+ _hard_return = false
289
+
290
+ # if line ends in a single \, either insert hard return into current block (with \n)
291
+ # or init line_buff to collect next line
292
+ if line[-1] == '\\'
293
+ if line[-2] != '\\'
294
+ nline = line[0...-1]
295
+ # check if line is effectively blank, but add leading whitespace back
296
+ # to maintain tab processing
297
+ if nline.strip == ''
298
+ line = "#{nline}\n"
299
+ line_end = ''
300
+ _hard_return = true
301
+ else
302
+ line_buff = nline
303
+ next
304
+ end
305
+ end
306
+ end
307
+
308
+ # detect tabs (must be consistent per-line)
309
+ _ind = 0
310
+ tab_str = line[0] == TAB ? TAB : nil
311
+ tab_str = line.index(TAB_SPACE) == 0 ? TAB_SPACE : nil if !tab_str
312
+ indent_str = ''
313
+ if tab_str
314
+ _ind += 1
315
+ _len = tab_str.length
316
+ _pos = _len
317
+ while line.index(tab_str, _pos)
318
+ _pos += _len
319
+ _ind += 1
320
+ end
321
+ line = line[_pos..-1]
322
+
323
+ # trim indent chars based on block_handler_indent
324
+ if indent > 0
325
+ _ind -= indent
326
+ _ind = 0 if _ind < 0
327
+ end
328
+ end
329
+
330
+ line_norm = Line.new(line, tab_str, _ind, line_start, oline.chomp)
331
+ line_start = line_count
332
+
333
+ if parse_tree.mode == :raw
334
+ stat = parse_tree.collect(line_norm)
335
+ next if stat != BH::RETRY
336
+ parse_tree.push_block
337
+ end
338
+
339
+ # macro processing
340
+ if line[0] == '@'
341
+ parse_tree.new_macro(line_norm, line_count)
342
+ next
343
+ elsif parse_tree.macro_delim_match(line_norm, line_count)
344
+ next
345
+ end
346
+
347
+ if parse_tree.mode == :block
348
+ stat = parse_tree.collect(line_norm)
349
+ if stat == BH::RETRY
350
+ parse_tree.push_block
351
+ else
352
+ next
353
+ end
354
+ end
355
+
356
+ # blank line and not in block
357
+ if line == ''
358
+ parse_tree.push_block
359
+ next
360
+ end
361
+
362
+ found = false
363
+ @blocks.each do |handler|
364
+ stat = handler.can_handle(line)
365
+ next if stat == BH::RETRY
366
+
367
+ parse_tree.new_block(handler, handler.current_type, line_norm, stat, line_count)
368
+ found = true
369
+ _trace "(#{handler.current_type}->#{handler}) #{line} -> #{stat}"
370
+ break
371
+ end
372
+
373
+ if !found
374
+ @blocks_map['p'].can_handle('p.')
375
+ parse_tree.new_block(@blocks_map['p'], 'p', line_norm, BH::COLLECT, line_count)
376
+ end
377
+ end
378
+ parse_tree.push_block
379
+ # @todo check if macros left open
380
+ rescue => ex
381
+ _error "Exception approximately on line: #{line}"
382
+ _error ex.message + "\t#{ex.backtrace.join("\n\t")}"
383
+ #_error "vars = #{@vars.inspect}"
384
+ end
385
+
386
+ parse_tree
387
+ end
388
+
389
+ # This string in a block indicates that a piped macro's output should be inserted at this location
390
+ # rather than immediately after last line. For now, this is only checked for on the last line.
391
+ #
392
+ # Multiple inline pipes can be specified on this line, with each pipe corresponding to each macro
393
+ # chained to the block. Any unfilled pipe will be replaced with a blank string.
394
+ #
395
+ # To escape the pipe, use a backslash anywhere [*AFTER*] the initial dash (e.g. `-\\>*<-`).
396
+ PIPE_INLINE = '->*<-'
397
+
398
+ # Goes through each item in parse tree, collecting output in the following manner:
399
+ #
400
+ # # {{String}}s and {{Line}}s are outputted as-is
401
+ # # macros and blocks with matching handlers get {{process}} called
402
+ #
403
+ # All output is joined with `\\n` by default.
404
+ #
405
+ # The output object and join string can be overridden through the {{opts}} parameter
406
+ # keys {{:out}} and {{:joiner}}.
407
+ #3
408
+ # @param Eggshell::ParseTree,Array parse_tree Parsed document.
409
+ # @param Integer call_depth
410
+ # @param Hash opts
411
+ def assemble(parse_tree, call_depth = 0, opts = {})
412
+ opts = {} if !opts.is_a?(Hash)
413
+ out = opts[:out] || get_out
414
+ joiner = opts[:join] || "\n"
415
+
416
+ parse_tree = parse_tree.tree if parse_tree.is_a?(Eggshell::ParseTree)
417
+ raise Exception.new("input not an array or ParseTree (depth=#{call_depth}") if !parse_tree.is_a?(Array)
418
+ # @todo defer process to next unit so macro can inject lines back into previous block
419
+
420
+ last_type = nil
421
+ last_line = 0
422
+ deferred = nil
423
+
424
+ parse_tree.each do |unit|
425
+ if unit.is_a?(String)
426
+ out << unit
427
+ last_line += 1
428
+ elsif unit.is_a?(Eggshell::Line)
429
+ out << unit.to_s
430
+ last_line = unit.line_nameum
431
+ elsif unit.is_a?(Array)
432
+ handler = unit[0] == :block ? @blocks_map[unit[1]] : @macros[unit[1]]
433
+ name = unit[1]
434
+
435
+ if !handler
436
+ _warn "handler not found: #{unit[0]} -> #{unit[1]}"
437
+ next
438
+ end
439
+
440
+ args_o = unit[2] || []
441
+ args = []
442
+ args_o.each do |arg|
443
+ args << expr_eval(arg)
444
+ end
445
+
446
+ lines = unit[ParseTree::IDX_LINES]
447
+ lines_start = unit[ParseTree::IDX_LINES_START]
448
+ lines_end = unit[ParseTree::IDX_LINES_END]
449
+
450
+ _handler, _name, _args, _lines = deferred
451
+
452
+ if unit[0] == :block
453
+ if deferred
454
+ # two cases:
455
+ # 1. this block is immediately tied to block-macro chain and is continuation of same type of block
456
+ # 2. part of block-macro chain but not same type, or immediately follows another block
457
+
458
+ if last_type == :macro && (lines_end - last_line == 1) && _name == name
459
+ lines.each do |line|
460
+ _lines << line
461
+ end
462
+ else
463
+ _handler.process(_name, _args, _lines, out, call_depth)
464
+ deferred = [handler, name, args, lines.clone]
465
+ end
466
+ else
467
+ deferred = [handler, name, args, lines.clone]
468
+ end
469
+
470
+ last_line = lines_end
471
+ else
472
+ # macro immediately after a block, so assume that output gets piped into last lines
473
+ # of closest block
474
+ if deferred && lines_start - last_line == 1
475
+ _last = _lines[-1]
476
+ pinline = false
477
+ pipe = _lines
478
+ if _last.to_s.index(PIPE_INLINE)
479
+ pipe = []
480
+ pinline = true
481
+ end
482
+
483
+ handler.process(name, args, lines, pipe, call_depth)
484
+
485
+ # inline pipe; join output with literal \n to avoid processing lines in block process
486
+ if pinline
487
+ if _last.is_a?(Eggshell::Line)
488
+ _lines[-1] = _last.replace(_last.line.sub(PIPE_INLINE, pipe.join('\n')))
489
+ else
490
+ _lines[-1] = _last.sub(PIPE_INLINE, pipe.join('\n'))
491
+ end
492
+ end
493
+ else
494
+ if deferred
495
+ _handler.process(_name, _args, _lines, out, call_depth)
496
+ deferred = nil
497
+ end
498
+ handler.process(name, args, lines, out, call_depth)
499
+ end
500
+ last_line = lines_end
501
+ end
502
+
503
+ last_type = unit[0]
504
+ elsif unit
505
+ _warn "not sure how to handle #{unit.class}"
506
+ _debug unit.inspect
507
+ end
508
+ end
509
+
510
+ if deferred
511
+ _handler, _name, _args, _lines = deferred
512
+ _handler.process(_name, _args, _lines, out, call_depth)
513
+ deferred = nil
514
+ end
515
+ out.join(joiner)
516
+ end
517
+
518
+ def process(lines, line_count = 0, call_depth = 0)
519
+ parse_tree = preprocess(lines, line_count)
520
+ assemble(parse_tree.tree, call_depth)
521
+ end
522
+
523
+ # Register inline format handlers with opening and closing tags.
524
+ # Typically, tags can be arbitrarily nested. However, nesting can be
525
+ # shut off completely or selectively by specifying 0 or more tags
526
+ # separated by a space (empty string is completely disabled).
527
+ #
528
+ # @param Array tags Each entry should be a 2- or 3-element array in the
529
+ # following form: {{[open, close[, non_nest]]}}
530
+ def add_format_handler(handler, tags)
531
+ return if !tags.is_a?(Array)
532
+
533
+ tags.each do |entry|
534
+ open, close, no_nest = entry
535
+ no_nest = '' if no_nest.is_a?(TrueClass)
536
+ @fmt_handlers[open] = [handler, close, no_nest]
537
+ _trace "add_format_handler: #{open} #{close} (non-nested: #{no_nest.inspect})"
538
+ end
539
+
540
+ # regenerate splitting pattern going from longest to shortest
541
+ openers = @fmt_handlers.keys.sort do |a, b|
542
+ b.length <=> a.length
543
+ end
544
+
545
+ regex = ''
546
+ openers.each do |op|
547
+ regex = "#{regex}|#{Regexp.quote(op)}|#{Regexp.quote(@fmt_handlers[op][1])}"
548
+ end
549
+
550
+ @fmt_regex = /(\\|'|"#{regex})/
551
+ end
552
+
553
+ # Expands inline formatting with {{Eggshell::FormatHandler}}s.
554
+ def expand_formatting(str)
555
+ toks = str.gsub(PIPE_INLINE, '').split(@fmt_regex)
556
+ toks.delete('')
557
+
558
+ buff = ['']
559
+ quote = nil
560
+ opened = []
561
+ closing = []
562
+ non_nesting = []
563
+
564
+ i = 0
565
+ while i < toks.length
566
+ tok = toks[i]
567
+ i += 1
568
+ if tok == '\\'
569
+ # preserve escape char otherwise we lose things like \n or \t
570
+ buff[-1] += tok + toks[i]
571
+ i += 1
572
+ elsif quote
573
+ quote = nil if tok == quote
574
+ buff[-1] += tok
575
+ elsif tok == '"' || tok == "'"
576
+ quote = tok if opened[-1]
577
+ buff[-1] += tok
578
+ elsif @fmt_handlers[tok] && (!non_nesting[-1] || non_nesting.index(tok))
579
+ handler, closer, non_nest = @fmt_handlers[tok]
580
+ opened << tok
581
+ closing << closer
582
+ non_nesting << non_nest
583
+ buff << ''
584
+ elsif tok == closing[-1]
585
+ opener = opened.pop
586
+ handler = @fmt_handlers[opener][0]
587
+ closing.pop
588
+ non_nesting.pop
589
+
590
+ # @todo insert placeholder and swap out at end? might be a prob if value has to be escaped
591
+ bstr = buff.pop
592
+ buff[-1] += handler.format(opener, bstr)
593
+ else
594
+ buff[-1] += tok
595
+ end
596
+ end
597
+
598
+ opened.each do |op|
599
+ bstr = buff.pop
600
+ buff[-1] += op + bstr
601
+ _warn "expand_formatting: unclosed #{op}, not doing anything: #{bstr}"
602
+ end
603
+
604
+ buff.join('')
605
+ end
606
+
607
+ def self.parse_block_start(line)
608
+ block_type = nil
609
+ args = []
610
+
611
+ bt = line.match(BLOCK_MATCH_PARAMS)
612
+ if bt
613
+ idx0 = bt[0].length
614
+ idx1 = line.index(').', idx0)
615
+ if idx1
616
+ block_type = line[0..idx0-2]
617
+ params = line[0...idx1+1].strip
618
+ line = line[idx1+2..line.length] || ''
619
+ if params != ''
620
+ struct = ExpressionEvaluator.struct(params)
621
+ args = struct[0][2]
622
+ end
623
+ end
624
+ else
625
+ block_type = line.match(BLOCK_MATCH)
626
+ if block_type && block_type[0].strip != ''
627
+ block_type = block_type[1]
628
+ len = block_type.length
629
+ block_type = block_type[0..-2] if block_type[-1] == '.'
630
+ line = line[len..line.length] || ''
631
+ else
632
+ block_type = nil
633
+ end
634
+ end
635
+
636
+ [block_type, args, line]
637
+ end
638
+
639
+ def self.parse_macro_start(line)
640
+ macro = nil
641
+ args = []
642
+ delim = nil
643
+
644
+ # either macro is a plain '@macro' or it has parameters/opening brace
645
+ if line.index(' ') || line.index('(') || line.index('{')
646
+ # since the macro statement is essentially a function call, parse the line as an expression to get components
647
+ expr_struct = ExpressionEvaluator.struct(line)
648
+ fn = expr_struct.shift
649
+ if fn.is_a?(Array) && (fn[0] == :fn || fn[0] == :var)
650
+ macro = fn[1][1..fn[1].length]
651
+ args = fn[2]
652
+ if expr_struct[-1].is_a?(Array) && expr_struct[-1][0] == :brace_op
653
+ delim = expr_struct[-1][1]
654
+ end
655
+ end
656
+ else
657
+ macro = line[1..line.length]
658
+ end
659
+
660
+ [macro, args, delim]
661
+ end
662
+
663
+ BACKSLASH_REGEX = /\\(u[0-9a-f]{4}|u\{[^}]+\}|.)/i
664
+ BACKSLASH_UNESCAPE_MAP = {
665
+ 'f' => "\f",
666
+ 'n' => "\n",
667
+ 'r' => "\r",
668
+ 't' => "\t",
669
+ 'v' => "\v"
670
+ }.freeze
671
+
672
+ # Unescapes backslashes and Unicode characters.
673
+ #
674
+ # If a match is made against {{BACKSLASH_UNESCAPE_MAP}} that character will
675
+ # be used, otherwise, the literal is used.
676
+ #
677
+ # Unicode sequences are standard Ruby-like syntax: {{\uabcd}} or {{\u{seq1 seq2 ...}}}.
678
+ def self.unescape(str)
679
+ str = str.gsub(BACKSLASH_REGEX) do |match|
680
+ if match.length == 2
681
+ c = match[1]
682
+ BACKSLASH_UNESCAPE_MAP[c] || c
683
+ else
684
+ if match[2] == '{'
685
+ parts = match[3..-1].split(' ')
686
+ buff = ''
687
+ parts.each do |part|
688
+ buff += [part.to_i(16)].pack('U')
689
+ end
690
+ buff
691
+ else
692
+ [match[2..-1].to_i(16)].pack('U')
693
+ end
694
+ end
695
+ end
696
+ end
697
+
698
+ def unescape(str)
699
+ return self.class.unescape(str)
700
+ end
701
+
702
+ # Calls inline formatting, expression extrapolator, and backslash unescape.
703
+ def expand_all(str)
704
+ unescape(expand_expr(expand_formatting(str)))
705
+ end
706
+ end
707
+ end