ezml 0.1.1

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,826 @@
1
+ # frozen_string_literal: true
2
+ require 'strscan'
3
+
4
+ module EZML
5
+ class Parser
6
+ include EZML::Util
7
+
8
+ attr_reader :root
9
+
10
+ # Designates an XHTML/XML element.
11
+ ELEMENT = ?%
12
+
13
+ # Designates a `<div>` element with the given class.
14
+ DIV_CLASS = ?.
15
+
16
+ # Designates a `<div>` element with the given id.
17
+ DIV_ID = ?#
18
+
19
+ # Designates an XHTML/XML comment.
20
+ COMMENT = ?/
21
+
22
+ # Designates an XHTML doctype or script that is never HTML-escaped.
23
+ DOCTYPE = ?!
24
+
25
+ # Designates script, the result of which is output.
26
+ SCRIPT = ?=
27
+
28
+ # Designates script that is always HTML-escaped.
29
+ SANITIZE = ?&
30
+
31
+ # Designates script, the result of which is flattened and output.
32
+ FLAT_SCRIPT = ?~
33
+
34
+ # Designates script which is run but not output.
35
+ SILENT_SCRIPT = ?-
36
+
37
+ # When following SILENT_SCRIPT, designates a comment that is not output.
38
+ SILENT_COMMENT = ?#
39
+
40
+ # Designates a non-parsed line.
41
+ ESCAPE = ?\\
42
+
43
+ # Designates a block of filtered text.
44
+ FILTER = ?:
45
+
46
+ # Designates a non-parsed line. Not actually a character.
47
+ PLAIN_TEXT = -1
48
+
49
+ # Keeps track of the ASCII values of the characters that begin a
50
+ # specially-interpreted line.
51
+ SPECIAL_CHARACTERS = [
52
+ ELEMENT,
53
+ DIV_CLASS,
54
+ DIV_ID,
55
+ COMMENT,
56
+ DOCTYPE,
57
+ SCRIPT,
58
+ SANITIZE,
59
+ FLAT_SCRIPT,
60
+ SILENT_SCRIPT,
61
+ ESCAPE,
62
+ FILTER
63
+ ]
64
+
65
+ # The value of the character that designates that a line is part
66
+ # of a multiline string.
67
+ MULTILINE_CHAR_VALUE = ?|
68
+
69
+ # Regex to check for blocks with spaces around arguments. Not to be confused
70
+ # with multiline script.
71
+ # For example:
72
+ # foo.each do | bar |
73
+ # = bar
74
+ #
75
+ BLOCK_WITH_SPACES = /do\s*\|\s*[^\|]*\s+\|\z/
76
+
77
+ MID_BLOCK_KEYWORDS = %w[else elsif rescue ensure end when]
78
+ START_BLOCK_KEYWORDS = %w[if begin case unless]
79
+ # Try to parse assignments to block starters as best as possible
80
+ START_BLOCK_KEYWORD_REGEX = /(?:\w+(?:,\s*\w+)*\s*=\s*)?(#{START_BLOCK_KEYWORDS.join('|')})/
81
+ BLOCK_KEYWORD_REGEX = /^-?\s*(?:(#{MID_BLOCK_KEYWORDS.join('|')})|#{START_BLOCK_KEYWORD_REGEX.source})\b/
82
+
83
+ # The Regex that matches a Doctype command.
84
+ DOCTYPE_REGEX = /(\d(?:\.\d)?)?\s*([a-z]*)\s*([^ ]+)?/i
85
+
86
+ # The Regex that matches a literal string or symbol value
87
+ LITERAL_VALUE_REGEX = /:(\w*)|(["'])((?!\\|\#\{|\#@|\#\$|\2).|\\.)*\2/
88
+
89
+ ID_KEY = 'id'.freeze
90
+ CLASS_KEY = 'class'.freeze
91
+
92
+ def initialize(options)
93
+ @options = Options.wrap(options)
94
+ # Record the indent levels of "if" statements to validate the subsequent
95
+ # elsif and else statements are indented at the appropriate level.
96
+ @script_level_stack = []
97
+ @template_index = 0
98
+ @template_tabs = 0
99
+ end
100
+
101
+ def call(template)
102
+ match = template.rstrip.scan(/(([ \t]+)?(.*?))(?:\Z|\r\n|\r|\n)/m)
103
+ # discard the last match which is always blank
104
+ match.pop
105
+ @template = match.each_with_index.map do |(full, whitespace, text), index|
106
+ Line.new(whitespace, text.rstrip, full, index, self, false)
107
+ end
108
+ # Append special end-of-document marker
109
+ @template << Line.new(nil, '-#', '-#', @template.size, self, true)
110
+
111
+ @root = @parent = ParseNode.new(:root)
112
+ @flat = false
113
+ @filter_buffer = nil
114
+ @indentation = nil
115
+ @line = next_line
116
+
117
+ raise SyntaxError.new(Error.message(:indenting_at_start), @line.index) if @line.tabs != 0
118
+
119
+ loop do
120
+ next_line
121
+
122
+ process_indent(@line) unless @line.text.empty?
123
+
124
+ if flat?
125
+ text = @line.full.dup
126
+ text = "" unless text.gsub!(/^#{@flat_spaces}/, '')
127
+ @filter_buffer << "#{text}\n"
128
+ @line = @next_line
129
+ next
130
+ end
131
+
132
+ @tab_up = nil
133
+ process_line(@line) unless @line.text.empty?
134
+ if block_opened? || @tab_up
135
+ @template_tabs += 1
136
+ @parent = @parent.children.last
137
+ end
138
+
139
+ if !flat? && @next_line.tabs - @line.tabs > 1
140
+ raise SyntaxError.new(Error.message(:deeper_indenting, @next_line.tabs - @line.tabs), @next_line.index)
141
+ end
142
+
143
+ @line = @next_line
144
+ end
145
+ # Close all the open tags
146
+ close until @parent.type == :root
147
+ @root
148
+ rescue EZML::Error => e
149
+ e.backtrace.unshift "#{@options.filename}:#{(e.line ? e.line + 1 : @line.index + 1) + @options.line - 1}"
150
+ raise
151
+ end
152
+
153
+ def compute_tabs(line)
154
+ return 0 if line.text.empty? || !line.whitespace
155
+
156
+ if @indentation.nil?
157
+ @indentation = line.whitespace
158
+
159
+ if @indentation.include?(?\s) && @indentation.include?(?\t)
160
+ raise SyntaxError.new(Error.message(:cant_use_tabs_and_spaces), line.index)
161
+ end
162
+
163
+ @flat_spaces = @indentation * (@template_tabs+1) if flat?
164
+ return 1
165
+ end
166
+
167
+ tabs = line.whitespace.length / @indentation.length
168
+ return tabs if line.whitespace == @indentation * tabs
169
+ return @template_tabs + 1 if flat? && line.whitespace =~ /^#{@flat_spaces}/
170
+
171
+ message = Error.message(:inconsistent_indentation,
172
+ human_indentation(line.whitespace),
173
+ human_indentation(@indentation)
174
+ )
175
+ raise SyntaxError.new(message, line.index)
176
+ end
177
+
178
+ private
179
+
180
+ # @private
181
+ class Line < Struct.new(:whitespace, :text, :full, :index, :parser, :eod)
182
+ alias_method :eod?, :eod
183
+
184
+ # @private
185
+ def tabs
186
+ @tabs ||= parser.compute_tabs(self)
187
+ end
188
+
189
+ def strip!(from)
190
+ self.text = text[from..-1]
191
+ self.text.lstrip!
192
+ self
193
+ end
194
+ end
195
+
196
+ # @private
197
+ class ParseNode < Struct.new(:type, :line, :value, :parent, :children)
198
+ def initialize(*args)
199
+ super
200
+ self.children ||= []
201
+ end
202
+
203
+ def inspect
204
+ %Q[(#{type} #{value.inspect}#{children.each_with_object('') {|c, s| s << "\n#{c.inspect.gsub!(/^/, ' ')}"}})]
205
+ end
206
+ end
207
+
208
+ # @param [String] new - Hash literal including dynamic values.
209
+ # @param [String] old - Hash literal including dynamic values or Ruby literal of multiple Hashes which MUST be interpreted as method's last arguments.
210
+ class DynamicAttributes < Struct.new(:new, :old)
211
+ def old=(value)
212
+ unless value =~ /\A{.*}\z/m
213
+ raise ArgumentError.new('Old attributes must start with "{" and end with "}"')
214
+ end
215
+ super
216
+ end
217
+
218
+ # This will be a literal for EZML::Buffer#attributes's last argument, `attributes_hashes`.
219
+ def to_literal
220
+ [new, stripped_old].compact.join(', ')
221
+ end
222
+
223
+ private
224
+
225
+ # For `%foo{ { foo: 1 }, bar: 2 }`, :old is "{ { foo: 1 }, bar: 2 }" and this method returns " { foo: 1 }, bar: 2 " for last argument.
226
+ def stripped_old
227
+ return nil if old.nil?
228
+ old.sub!(/\A{/, '').sub!(/}\z/m, '')
229
+ end
230
+ end
231
+
232
+ # Processes and deals with lowering indentation.
233
+ def process_indent(line)
234
+ return unless line.tabs <= @template_tabs && @template_tabs > 0
235
+
236
+ to_close = @template_tabs - line.tabs
237
+ to_close.times {|i| close unless to_close - 1 - i == 0 && continuation_script?(line.text)}
238
+ end
239
+
240
+ def continuation_script?(text)
241
+ text[0] == SILENT_SCRIPT && mid_block_keyword?(text)
242
+ end
243
+
244
+ def mid_block_keyword?(text)
245
+ MID_BLOCK_KEYWORDS.include?(block_keyword(text))
246
+ end
247
+
248
+ # Processes a single line of EZML.
249
+ #
250
+ # This method doesn't return anything; it simply processes the line and
251
+ # adds the appropriate code to `@precompiled`.
252
+ def process_line(line)
253
+ case line.text[0]
254
+ when DIV_CLASS; push div(line)
255
+ when DIV_ID
256
+ return push plain(line) if %w[{ @ $].include?(line.text[1])
257
+ push div(line)
258
+ when ELEMENT; push tag(line)
259
+ when COMMENT; push comment(line.text[1..-1].lstrip)
260
+ when SANITIZE
261
+ return push plain(line.strip!(3), :escape_html) if line.text[1, 2] == '=='
262
+ return push script(line.strip!(2), :escape_html) if line.text[1] == SCRIPT
263
+ return push flat_script(line.strip!(2), :escape_html) if line.text[1] == FLAT_SCRIPT
264
+ return push plain(line.strip!(1), :escape_html) if line.text[1] == ?\s || line.text[1..2] == '#{'
265
+ push plain(line)
266
+ when SCRIPT
267
+ return push plain(line.strip!(2)) if line.text[1] == SCRIPT
268
+ line.text = line.text[1..-1]
269
+ push script(line)
270
+ when FLAT_SCRIPT; push flat_script(line.strip!(1))
271
+ when SILENT_SCRIPT
272
+ return push ezml_comment(line.text[2..-1]) if line.text[1] == SILENT_COMMENT
273
+ push silent_script(line)
274
+ when FILTER; push filter(line.text[1..-1].downcase)
275
+ when DOCTYPE
276
+ return push doctype(line.text) if line.text[0, 3] == '!!!'
277
+ return push plain(line.strip!(3), false) if line.text[1, 2] == '=='
278
+ return push script(line.strip!(2), false) if line.text[1] == SCRIPT
279
+ return push flat_script(line.strip!(2), false) if line.text[1] == FLAT_SCRIPT
280
+ return push plain(line.strip!(1), false) if line.text[1] == ?\s || line.text[1..2] == '#{'
281
+ push plain(line)
282
+ when ESCAPE
283
+ line.text = line.text[1..-1]
284
+ push plain(line)
285
+ else; push plain(line)
286
+ end
287
+ end
288
+
289
+ def block_keyword(text)
290
+ return unless keyword = text.scan(BLOCK_KEYWORD_REGEX)[0]
291
+ keyword[0] || keyword[1]
292
+ end
293
+
294
+ def push(node)
295
+ @parent.children << node
296
+ node.parent = @parent
297
+ end
298
+
299
+ def plain(line, escape_html = nil)
300
+ if block_opened?
301
+ raise SyntaxError.new(Error.message(:illegal_nesting_plain), @next_line.index)
302
+ end
303
+
304
+ unless contains_interpolation?(line.text)
305
+ return ParseNode.new(:plain, line.index + 1, :text => line.text)
306
+ end
307
+
308
+ escape_html = @options.escape_html if escape_html.nil?
309
+ line.text = unescape_interpolation(line.text, escape_html)
310
+ script(line, false)
311
+ end
312
+
313
+ def script(line, escape_html = nil, preserve = false)
314
+ raise SyntaxError.new(Error.message(:no_ruby_code, '=')) if line.text.empty?
315
+ line = handle_ruby_multiline(line)
316
+ escape_html = @options.escape_html if escape_html.nil?
317
+
318
+ keyword = block_keyword(line.text)
319
+ check_push_script_stack(keyword)
320
+
321
+ ParseNode.new(:script, line.index + 1, :text => line.text, :escape_html => escape_html,
322
+ :preserve => preserve, :keyword => keyword)
323
+ end
324
+
325
+ def flat_script(line, escape_html = nil)
326
+ raise SyntaxError.new(Error.message(:no_ruby_code, '~')) if line.text.empty?
327
+ script(line, escape_html, :preserve)
328
+ end
329
+
330
+ def silent_script(line)
331
+ raise SyntaxError.new(Error.message(:no_end), line.index) if line.text[1..-1].strip == 'end'
332
+
333
+ line = handle_ruby_multiline(line)
334
+ keyword = block_keyword(line.text)
335
+
336
+ check_push_script_stack(keyword)
337
+
338
+ if ["else", "elsif", "when"].include?(keyword)
339
+ if @script_level_stack.empty?
340
+ raise EZML::SyntaxError.new(Error.message(:missing_if, keyword), @line.index)
341
+ end
342
+
343
+ if keyword == 'when' and !@script_level_stack.last[2]
344
+ if @script_level_stack.last[1] + 1 == @line.tabs
345
+ @script_level_stack.last[1] += 1
346
+ end
347
+ @script_level_stack.last[2] = true
348
+ end
349
+
350
+ if @script_level_stack.last[1] != @line.tabs
351
+ message = Error.message(:bad_script_indent, keyword, @script_level_stack.last[1], @line.tabs)
352
+ raise EZML::SyntaxError.new(message, @line.index)
353
+ end
354
+ end
355
+
356
+ ParseNode.new(:silent_script, @line.index + 1,
357
+ :text => line.text[1..-1], :keyword => keyword)
358
+ end
359
+
360
+ def check_push_script_stack(keyword)
361
+ if ["if", "case", "unless"].include?(keyword)
362
+ # @script_level_stack contents are arrays of form
363
+ # [:keyword, stack_level, other_info]
364
+ @script_level_stack.push([keyword.to_sym, @line.tabs])
365
+ @script_level_stack.last << false if keyword == 'case'
366
+ @tab_up = true
367
+ end
368
+ end
369
+
370
+ def ezml_comment(text)
371
+ if filter_opened?
372
+ @flat = true
373
+ @filter_buffer = String.new
374
+ @filter_buffer << "#{text}\n" unless text.empty?
375
+ text = @filter_buffer
376
+ # If we don't know the indentation by now, it'll be set in Line#tabs
377
+ @flat_spaces = @indentation * (@template_tabs+1) if @indentation
378
+ end
379
+
380
+ ParseNode.new(:ezml_comment, @line.index + 1, :text => text)
381
+ end
382
+
383
+ def tag(line)
384
+ tag_name, attributes, attributes_hashes, object_ref, nuke_outer_whitespace,
385
+ nuke_inner_whitespace, action, value, last_line = parse_tag(line.text)
386
+
387
+ preserve_tag = @options.preserve.include?(tag_name)
388
+ nuke_inner_whitespace ||= preserve_tag
389
+ escape_html = (action == '&' || (action != '!' && @options.escape_html))
390
+
391
+ case action
392
+ when '/'; self_closing = true
393
+ when '~'; parse = preserve_script = true
394
+ when '='
395
+ parse = true
396
+ if value[0] == ?=
397
+ value = unescape_interpolation(value[1..-1].strip, escape_html)
398
+ escape_html = false
399
+ end
400
+ when '&', '!'
401
+ if value[0] == ?= || value[0] == ?~
402
+ parse = true
403
+ preserve_script = (value[0] == ?~)
404
+ if value[1] == ?=
405
+ value = unescape_interpolation(value[2..-1].strip, escape_html)
406
+ escape_html = false
407
+ else
408
+ value = value[1..-1].strip
409
+ end
410
+ elsif contains_interpolation?(value)
411
+ value = unescape_interpolation(value, escape_html)
412
+ parse = true
413
+ escape_html = false
414
+ end
415
+ else
416
+ if contains_interpolation?(value)
417
+ value = unescape_interpolation(value, escape_html)
418
+ parse = true
419
+ escape_html = false
420
+ end
421
+ end
422
+
423
+ attributes = Parser.parse_class_and_id(attributes)
424
+ dynamic_attributes = DynamicAttributes.new
425
+
426
+ if attributes_hashes[:new]
427
+ static_attributes, attributes_hash = attributes_hashes[:new]
428
+ AttributeBuilder.merge_attributes!(attributes, static_attributes) if static_attributes
429
+ dynamic_attributes.new = attributes_hash
430
+ end
431
+
432
+ if attributes_hashes[:old]
433
+ static_attributes = parse_static_hash(attributes_hashes[:old])
434
+ AttributeBuilder.merge_attributes!(attributes, static_attributes) if static_attributes
435
+ dynamic_attributes.old = attributes_hashes[:old] unless static_attributes || @options.suppress_eval
436
+ end
437
+
438
+ raise SyntaxError.new(Error.message(:illegal_nesting_self_closing), @next_line.index) if block_opened? && self_closing
439
+ raise SyntaxError.new(Error.message(:no_ruby_code, action), last_line - 1) if parse && value.empty?
440
+ raise SyntaxError.new(Error.message(:self_closing_content), last_line - 1) if self_closing && !value.empty?
441
+
442
+ if block_opened? && !value.empty? && !is_ruby_multiline?(value)
443
+ raise SyntaxError.new(Error.message(:illegal_nesting_line, tag_name), @next_line.index)
444
+ end
445
+
446
+ self_closing ||= !!(!block_opened? && value.empty? && @options.autoclose.any? {|t| t === tag_name})
447
+ value = nil if value.empty? && (block_opened? || self_closing)
448
+ line.text = value
449
+ line = handle_ruby_multiline(line) if parse
450
+
451
+ ParseNode.new(:tag, line.index + 1, :name => tag_name, :attributes => attributes,
452
+ :dynamic_attributes => dynamic_attributes, :self_closing => self_closing,
453
+ :nuke_inner_whitespace => nuke_inner_whitespace,
454
+ :nuke_outer_whitespace => nuke_outer_whitespace, :object_ref => object_ref,
455
+ :escape_html => escape_html, :preserve_tag => preserve_tag,
456
+ :preserve_script => preserve_script, :parse => parse, :value => line.text)
457
+ end
458
+
459
+ # Renders a line that creates an XHTML tag and has an implicit div because of
460
+ # `.` or `#`.
461
+ def div(line)
462
+ line.text = "%div#{line.text}"
463
+ tag(line)
464
+ end
465
+
466
+ # Renders an XHTML comment.
467
+ def comment(text)
468
+ if text[0..1] == '!['
469
+ revealed = true
470
+ text = text[1..-1]
471
+ else
472
+ revealed = false
473
+ end
474
+
475
+ conditional, text = balance(text, ?[, ?]) if text[0] == ?[
476
+ text.strip!
477
+
478
+ if contains_interpolation?(text)
479
+ parse = true
480
+ text = unescape_interpolation(text)
481
+ else
482
+ parse = false
483
+ end
484
+
485
+ if block_opened? && !text.empty?
486
+ raise SyntaxError.new(EZML::Error.message(:illegal_nesting_content), @next_line.index)
487
+ end
488
+
489
+ ParseNode.new(:comment, @line.index + 1, :conditional => conditional, :text => text, :revealed => revealed, :parse => parse)
490
+ end
491
+
492
+ # Renders an XHTML doctype or XML shebang.
493
+ def doctype(text)
494
+ raise SyntaxError.new(Error.message(:illegal_nesting_header), @next_line.index) if block_opened?
495
+ version, type, encoding = text[3..-1].strip.downcase.scan(DOCTYPE_REGEX)[0]
496
+ ParseNode.new(:doctype, @line.index + 1, :version => version, :type => type, :encoding => encoding)
497
+ end
498
+
499
+ def filter(name)
500
+ raise Error.new(Error.message(:invalid_filter_name, name)) unless name =~ /^\w+$/
501
+
502
+ if filter_opened?
503
+ @flat = true
504
+ @filter_buffer = String.new
505
+ # If we don't know the indentation by now, it'll be set in Line#tabs
506
+ @flat_spaces = @indentation * (@template_tabs+1) if @indentation
507
+ end
508
+
509
+ ParseNode.new(:filter, @line.index + 1, :name => name, :text => @filter_buffer)
510
+ end
511
+
512
+ def close
513
+ node, @parent = @parent, @parent.parent
514
+ @template_tabs -= 1
515
+ send("close_#{node.type}", node) if respond_to?("close_#{node.type}", :include_private)
516
+ end
517
+
518
+ def close_filter(_)
519
+ close_flat_section
520
+ end
521
+
522
+ def close_ezml_comment(_)
523
+ close_flat_section
524
+ end
525
+
526
+ def close_flat_section
527
+ @flat = false
528
+ @flat_spaces = nil
529
+ @filter_buffer = nil
530
+ end
531
+
532
+ def close_silent_script(node)
533
+ @script_level_stack.pop if ["if", "case", "unless"].include? node.value[:keyword]
534
+
535
+ # Post-process case statements to normalize the nesting of "when" clauses
536
+ return unless node.value[:keyword] == "case"
537
+ return unless first = node.children.first
538
+ return unless first.type == :silent_script && first.value[:keyword] == "when"
539
+ return if first.children.empty?
540
+ # If the case node has a "when" child with children, it's the
541
+ # only child. Then we want to put everything nested beneath it
542
+ # beneath the case itself (just like "if").
543
+ node.children = [first, *first.children]
544
+ first.children = []
545
+ end
546
+
547
+ alias :close_script :close_silent_script
548
+
549
+ # This is a class method so it can be accessed from {EZML::Helpers}.
550
+ #
551
+ # Iterates through the classes and ids supplied through `.`
552
+ # and `#` syntax, and returns a hash with them as attributes,
553
+ # that can then be merged with another attributes hash.
554
+ def self.parse_class_and_id(list)
555
+ attributes = {}
556
+ return attributes if list.empty?
557
+
558
+ list.scan(/([#.])([-:_a-zA-Z0-9\@]+)/) do |type, property|
559
+ case type
560
+ when '.'
561
+ if attributes[CLASS_KEY]
562
+ attributes[CLASS_KEY] += " "
563
+ else
564
+ attributes[CLASS_KEY] = ""
565
+ end
566
+ attributes[CLASS_KEY] += property
567
+ when '#'; attributes[ID_KEY] = property
568
+ end
569
+ end
570
+ attributes
571
+ end
572
+
573
+ # This method doesn't use EZML::AttributeParser because currently it depends on Ripper and Rubinius doesn't provide it.
574
+ # Ideally this logic should be placed in EZML::AttributeParser instead of here and this method should use it.
575
+ #
576
+ # @param [String] text - Hash literal or text inside old attributes
577
+ # @return [Hash,nil] - Return nil if text is not static Hash literal
578
+ def parse_static_hash(text)
579
+ attributes = {}
580
+ return attributes if text.empty?
581
+
582
+ text = text[1...-1] # strip brackets
583
+ scanner = StringScanner.new(text)
584
+ scanner.scan(/\s+/)
585
+ until scanner.eos?
586
+ return unless key = scanner.scan(LITERAL_VALUE_REGEX)
587
+ return unless scanner.scan(/\s*=>\s*/)
588
+ return unless value = scanner.scan(LITERAL_VALUE_REGEX)
589
+ return unless scanner.scan(/\s*(?:,|$)\s*/)
590
+ attributes[eval(key).to_s] = eval(value).to_s
591
+ end
592
+ attributes
593
+ end
594
+
595
+ # Parses a line into tag_name, attributes, attributes_hash, object_ref, action, value
596
+ def parse_tag(text)
597
+ match = text.scan(/%([-:\w]+)([-:\w.#\@]*)(.+)?/)[0]
598
+ raise SyntaxError.new(Error.message(:invalid_tag, text)) unless match
599
+
600
+ tag_name, attributes, rest = match
601
+
602
+ if !attributes.empty? && (attributes =~ /[.#](\.|#|\z)/)
603
+ raise SyntaxError.new(Error.message(:illegal_element))
604
+ end
605
+
606
+ new_attributes_hash = old_attributes_hash = last_line = nil
607
+ object_ref = :nil
608
+ attributes_hashes = {}
609
+ while rest && !rest.empty?
610
+ case rest[0]
611
+ when ?{
612
+ break if old_attributes_hash
613
+ old_attributes_hash, rest, last_line = parse_old_attributes(rest)
614
+ attributes_hashes[:old] = old_attributes_hash
615
+ when ?(
616
+ break if new_attributes_hash
617
+ new_attributes_hash, rest, last_line = parse_new_attributes(rest)
618
+ attributes_hashes[:new] = new_attributes_hash
619
+ when ?[
620
+ break unless object_ref == :nil
621
+ object_ref, rest = balance(rest, ?[, ?])
622
+ else; break
623
+ end
624
+ end
625
+
626
+ if rest && !rest.empty?
627
+ nuke_whitespace, action, value = rest.scan(/(<>|><|[><])?([=\/\~&!])?(.*)?/)[0]
628
+ if nuke_whitespace
629
+ nuke_outer_whitespace = nuke_whitespace.include? '>'
630
+ nuke_inner_whitespace = nuke_whitespace.include? '<'
631
+ end
632
+ end
633
+
634
+ if @options.remove_whitespace
635
+ nuke_outer_whitespace = true
636
+ nuke_inner_whitespace = true
637
+ end
638
+
639
+ if value.nil?
640
+ value = ''
641
+ else
642
+ value.strip!
643
+ end
644
+ [tag_name, attributes, attributes_hashes, object_ref, nuke_outer_whitespace,
645
+ nuke_inner_whitespace, action, value, last_line || @line.index + 1]
646
+ end
647
+
648
+ # @return [String] attributes_hash - Hash literal starting with `{` and ending with `}`
649
+ # @return [String] rest
650
+ # @return [Integer] last_line
651
+ def parse_old_attributes(text)
652
+ text = text.dup
653
+ last_line = @line.index + 1
654
+
655
+ begin
656
+ attributes_hash, rest = balance(text, ?{, ?})
657
+ rescue SyntaxError => e
658
+ if text.strip[-1] == ?, && e.message == Error.message(:unbalanced_brackets)
659
+ text << "\n#{@next_line.text}"
660
+ last_line += 1
661
+ next_line
662
+ retry
663
+ end
664
+
665
+ raise e
666
+ end
667
+
668
+ return attributes_hash, rest, last_line
669
+ end
670
+
671
+ # @return [Array<Hash,String,nil>] - [static_attributes (Hash), dynamic_attributes (nil or String starting with `{` and ending with `}`)]
672
+ # @return [String] rest
673
+ # @return [Integer] last_line
674
+ def parse_new_attributes(text)
675
+ scanner = StringScanner.new(text)
676
+ last_line = @line.index + 1
677
+ attributes = {}
678
+
679
+ scanner.scan(/\(\s*/)
680
+ loop do
681
+ name, value = parse_new_attribute(scanner)
682
+ break if name.nil?
683
+
684
+ if name == false
685
+ scanned = EZML::Util.balance(text, ?(, ?))
686
+ text = scanned ? scanned.first : text
687
+ raise EZML::SyntaxError.new(Error.message(:invalid_attribute_list, text.inspect), last_line - 1)
688
+ end
689
+ attributes[name] = value
690
+ scanner.scan(/\s*/)
691
+
692
+ if scanner.eos?
693
+ text << " #{@next_line.text}"
694
+ last_line += 1
695
+ next_line
696
+ scanner.scan(/\s*/)
697
+ end
698
+ end
699
+
700
+ static_attributes = {}
701
+ dynamic_attributes = "{".dup
702
+ attributes.each do |name, (type, val)|
703
+ if type == :static
704
+ static_attributes[name] = val
705
+ else
706
+ dynamic_attributes << "#{inspect_obj(name)} => #{val},"
707
+ end
708
+ end
709
+ dynamic_attributes << "}"
710
+ dynamic_attributes = nil if dynamic_attributes == "{}"
711
+
712
+ return [static_attributes, dynamic_attributes], scanner.rest, last_line
713
+ end
714
+
715
+ def parse_new_attribute(scanner)
716
+ unless name = scanner.scan(/[-:\w]+/)
717
+ return if scanner.scan(/\)/)
718
+ return false
719
+ end
720
+
721
+ scanner.scan(/\s*/)
722
+ return name, [:static, true] unless scanner.scan(/=/) #/end
723
+
724
+ scanner.scan(/\s*/)
725
+ unless quote = scanner.scan(/["']/)
726
+ return false unless var = scanner.scan(/(@@?|\$)?\w+/)
727
+ return name, [:dynamic, var]
728
+ end
729
+
730
+ re = /((?:\\.|\#(?!\{)|[^#{quote}\\#])*)(#{quote}|#\{)/
731
+ content = []
732
+ loop do
733
+ return false unless scanner.scan(re)
734
+ content << [:str, scanner[1].gsub(/\\(.)/, '\1')]
735
+ break if scanner[2] == quote
736
+ content << [:ruby, balance(scanner, ?{, ?}, 1).first[0...-1]]
737
+ end
738
+
739
+ return name, [:static, content.first[1]] if content.size == 1
740
+ return name, [:dynamic,
741
+ %!"#{content.each_with_object(''.dup) {|(t, v), s| s << (t == :str ? inspect_obj(v)[1...-1] : "\#{#{v}}")}}"!]
742
+ end
743
+
744
+ def next_line
745
+ line = @template.shift || raise(StopIteration)
746
+
747
+ # `flat?' here is a little outdated,
748
+ # so we have to manually check if either the previous or current line
749
+ # closes the flat block, as well as whether a new block is opened.
750
+ line_defined = instance_variable_defined?(:@line)
751
+ @line.tabs if line_defined
752
+ unless (flat? && !closes_flat?(line) && !closes_flat?(@line)) ||
753
+ (line_defined && @line.text[0] == ?: && line.full =~ %r[^#{@line.full[/^\s+/]}\s])
754
+ return next_line if line.text.empty?
755
+
756
+ handle_multiline(line)
757
+ end
758
+
759
+ @next_line = line
760
+ end
761
+
762
+ def closes_flat?(line)
763
+ line && !line.text.empty? && line.full !~ /^#{@flat_spaces}/
764
+ end
765
+
766
+ def handle_multiline(line)
767
+ return unless is_multiline?(line.text)
768
+ line.text.slice!(-1)
769
+ loop do
770
+ new_line = @template.first
771
+ break if new_line.eod?
772
+ next @template.shift if new_line.text.strip.empty?
773
+ break unless is_multiline?(new_line.text.strip)
774
+ line.text << new_line.text.strip[0...-1]
775
+ @template.shift
776
+ end
777
+ end
778
+
779
+ # Checks whether or not `line` is in a multiline sequence.
780
+ def is_multiline?(text)
781
+ text && text.length > 1 && text[-1] == MULTILINE_CHAR_VALUE && text[-2] == ?\s && text !~ BLOCK_WITH_SPACES
782
+ end
783
+
784
+ def handle_ruby_multiline(line)
785
+ line.text.rstrip!
786
+ return line unless is_ruby_multiline?(line.text)
787
+ begin
788
+ # Use already fetched @next_line in the first loop. Otherwise, fetch next
789
+ new_line = new_line.nil? ? @next_line : @template.shift
790
+ break if new_line.eod?
791
+ next if new_line.text.empty?
792
+ line.text << " #{new_line.text.rstrip}"
793
+ end while is_ruby_multiline?(new_line.text)
794
+ next_line
795
+ line
796
+ end
797
+
798
+ # `text' is a Ruby multiline block if it:
799
+ # - ends with a comma
800
+ # - but not "?," which is a character literal
801
+ # (however, "x?," is a method call and not a literal)
802
+ # - and not "?\," which is a character literal
803
+ def is_ruby_multiline?(text)
804
+ text && text.length > 1 && text[-1] == ?, &&
805
+ !((text[-3, 2] =~ /\W\?/) || text[-3, 2] == "?\\")
806
+ end
807
+
808
+ def balance(*args)
809
+ EZML::Util.balance(*args) or raise(SyntaxError.new(Error.message(:unbalanced_brackets)))
810
+ end
811
+
812
+ def block_opened?
813
+ @next_line.tabs > @line.tabs
814
+ end
815
+
816
+ # Same semantics as block_opened?, except that block_opened? uses Line#tabs,
817
+ # which doesn't interact well with filter lines
818
+ def filter_opened?
819
+ @next_line.full =~ (@indentation ? /^#{@indentation * (@template_tabs + 1)}/ : /^\s/)
820
+ end
821
+
822
+ def flat?
823
+ @flat
824
+ end
825
+ end
826
+ end