haml_parser 0.1.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,10 @@
1
+ module HamlParser
2
+ class Error < StandardError
3
+ attr_accessor :lineno
4
+
5
+ def initialize(message, lineno)
6
+ super(message)
7
+ @lineno = lineno
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,58 @@
1
+ require 'haml_parser/ast'
2
+
3
+ module HamlParser
4
+ class FilterParser
5
+ def initialize(indent_tracker)
6
+ @ast = nil
7
+ @indent_level = nil
8
+ @indent_tracker = indent_tracker
9
+ end
10
+
11
+ def enabled?
12
+ !!@ast
13
+ end
14
+
15
+ def start(name, filename, lineno)
16
+ @ast = Ast::Filter.new
17
+ @ast.name = name
18
+ @ast.filename = filename
19
+ @ast.lineno = lineno
20
+ end
21
+
22
+ def append(line)
23
+ indent, text = @indent_tracker.split(line)
24
+ if text.empty?
25
+ @ast.texts << ''
26
+ return
27
+ end
28
+ indent_level = indent.size
29
+
30
+ if @indent_level
31
+ if indent_level < @indent_level
32
+ # Finish filter
33
+ @indent_level = nil
34
+ ast = @ast
35
+ @ast = nil
36
+ return ast
37
+ end
38
+ else
39
+ if indent_level > @indent_tracker.current_level
40
+ # Start filter
41
+ @indent_level = indent_level
42
+ else
43
+ # Empty filter
44
+ @ast = nil
45
+ return nil
46
+ end
47
+ end
48
+
49
+ text = line[@indent_level .. -1]
50
+ @ast.texts << text
51
+ nil
52
+ end
53
+
54
+ def finish
55
+ @ast
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,116 @@
1
+ require 'haml_parser/error'
2
+
3
+ module HamlParser
4
+ class IndentTracker
5
+ class IndentMismatch < Error
6
+ attr_reader :current_level, :indent_levels
7
+
8
+ def initialize(current_level, indent_levels, lineno)
9
+ super("Unexpected indent level: #{current_level}: indent_level=#{indent_levels}", lineno)
10
+ @current_level = current_level
11
+ @indent_levels = indent_levels
12
+ end
13
+ end
14
+
15
+ class InconsistentIndent < Error
16
+ attr_reader :previous_size, :current_size
17
+
18
+ def initialize(previous_size, current_size, lineno)
19
+ super("Inconsistent indentation: #{current_size} spaces used for indentation, but the rest of the document was indented using #{previous_size} spaces.", lineno)
20
+ @previous_size = previous_size
21
+ @current_size = current_size
22
+ end
23
+ end
24
+
25
+ class HardTabNotAllowed < Error
26
+ def initialize(lineno)
27
+ super('Indentation with hard tabs are not allowed :-p', lineno)
28
+ end
29
+ end
30
+
31
+ def initialize(on_enter: nil, on_leave: nil)
32
+ @indent_levels = [0]
33
+ @on_enter = on_enter || lambda { |level, text| }
34
+ @on_leave = on_leave || lambda { |level, text| }
35
+ @comment_level = nil
36
+ end
37
+
38
+ def process(line, lineno)
39
+ if line =~ /\A\t/
40
+ raise HardTabNotAllowed.new(lineno)
41
+ end
42
+ indent, text = split(line)
43
+ indent_level = indent.size
44
+
45
+ unless text.empty?
46
+ track(indent_level, text, lineno)
47
+ end
48
+ [text, indent]
49
+ end
50
+
51
+ def split(line)
52
+ m = line.match(/\A( *)(.*)\z/)
53
+ [m[1], m[2]]
54
+ end
55
+
56
+ def finish
57
+ indent_leave(0, '', -1)
58
+ end
59
+
60
+ def current_level
61
+ @indent_levels.last
62
+ end
63
+
64
+ def enter_comment!
65
+ @comment_level = @indent_levels[-2]
66
+ end
67
+
68
+ def check_indent_level!(lineno)
69
+ if @indent_levels.size >= 3
70
+ previous_size = @indent_levels[-2] - @indent_levels[-3]
71
+ current_size = @indent_levels[-1] - @indent_levels[-2]
72
+ if previous_size != current_size
73
+ raise InconsistentIndent.new(previous_size, current_size, lineno)
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def track(indent_level, text, lineno)
81
+ if indent_level > @indent_levels.last
82
+ indent_enter(indent_level, text, lineno)
83
+ elsif indent_level < @indent_levels.last
84
+ indent_leave(indent_level, text, lineno)
85
+ end
86
+ end
87
+
88
+ def indent_enter(indent_level, text, lineno)
89
+ unless @comment_level
90
+ @indent_levels.push(indent_level)
91
+ @on_enter.call(indent_level, text)
92
+ end
93
+ end
94
+
95
+ def indent_leave(indent_level, text, lineno)
96
+ if @comment_level
97
+ if indent_level <= @comment_level
98
+ # finish comment mode
99
+ @comment_level = nil
100
+ else
101
+ # still in comment
102
+ return
103
+ end
104
+ end
105
+
106
+ while indent_level < @indent_levels.last
107
+ @indent_levels.pop
108
+ @on_leave.call(indent_level, text)
109
+ end
110
+
111
+ if indent_level != @indent_levels.last
112
+ raise IndentMismatch.new(indent_level, @indent_levels.dup, lineno)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,67 @@
1
+ module HamlParser
2
+ class LineParser
3
+ attr_reader :filename, :lineno
4
+
5
+ def initialize(filename, template_str)
6
+ @filename = filename
7
+ @lines = template_str.each_line.map { |line| line.chomp.rstrip }
8
+ @lineno = 0
9
+ end
10
+
11
+ def next_line(in_filter: false)
12
+ line = move_next
13
+ if !in_filter && is_multiline?(line)
14
+ next_multiline(line)
15
+ else
16
+ line
17
+ end
18
+ end
19
+
20
+ def has_next?
21
+ @lineno < @lines.size
22
+ end
23
+
24
+ private
25
+
26
+ MULTILINE_SUFFIX = ' |'
27
+
28
+ # Regex to check for blocks with spaces around arguments. Not to be confused
29
+ # with multiline script.
30
+ # For example:
31
+ # foo.each do | bar |
32
+ # = bar
33
+ #
34
+ BLOCK_WITH_SPACES = /do\s*\|\s*[^\|]*\s+\|\z/o
35
+
36
+ def is_multiline?(line)
37
+ line = line.lstrip
38
+ line.end_with?(MULTILINE_SUFFIX) && line !~ BLOCK_WITH_SPACES
39
+ end
40
+
41
+ def move_next
42
+ @lines[@lineno].tap do
43
+ @lineno += 1
44
+ end
45
+ end
46
+
47
+ def move_back
48
+ @lineno -= 1
49
+ end
50
+
51
+ def next_multiline(line)
52
+ buf = [line[0, line.size-1]]
53
+ while @lineno < @lines.size
54
+ line = move_next
55
+
56
+ if is_multiline?(line)
57
+ line = line[0, line.size-1]
58
+ buf << line.lstrip
59
+ else
60
+ move_back
61
+ break
62
+ end
63
+ end
64
+ buf.join("\n")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,239 @@
1
+ require 'haml_parser/ast'
2
+ require 'haml_parser/element_parser'
3
+ require 'haml_parser/error'
4
+ require 'haml_parser/filter_parser'
5
+ require 'haml_parser/indent_tracker'
6
+ require 'haml_parser/line_parser'
7
+ require 'haml_parser/ruby_multiline'
8
+ require 'haml_parser/script_parser'
9
+ require 'haml_parser/utils'
10
+
11
+ module HamlParser
12
+ class Parser
13
+ def initialize(options = {})
14
+ @filename = options[:filename]
15
+ end
16
+
17
+ def call(template_str)
18
+ @ast = Ast::Root.new
19
+ @stack = []
20
+ @line_parser = LineParser.new(@filename, template_str)
21
+ @indent_tracker = IndentTracker.new(on_enter: method(:indent_enter), on_leave: method(:indent_leave))
22
+ @filter_parser = FilterParser.new(@indent_tracker)
23
+
24
+ while @line_parser.has_next?
25
+ in_filter = !@ast.is_a?(Ast::HamlComment) && @filter_parser.enabled?
26
+ line = @line_parser.next_line(in_filter: in_filter)
27
+ if in_filter
28
+ ast = @filter_parser.append(line)
29
+ if ast
30
+ @ast << ast
31
+ end
32
+ end
33
+ unless @filter_parser.enabled?
34
+ line_count = line.count("\n")
35
+ line.delete!("\n")
36
+ parse_line(line)
37
+ line_count.times do
38
+ @ast << create_node(Ast::Empty)
39
+ end
40
+ end
41
+ end
42
+
43
+ ast = @filter_parser.finish
44
+ if ast
45
+ @ast << ast
46
+ end
47
+ @indent_tracker.finish
48
+ @ast
49
+ rescue Error => e
50
+ if @filename && e.lineno
51
+ e.backtrace.unshift "#{@filename}:#{e.lineno}"
52
+ end
53
+ raise e
54
+ end
55
+
56
+ private
57
+
58
+ DOCTYPE_PREFIX = '!'
59
+ ELEMENT_PREFIX = '%'
60
+ COMMENT_PREFIX = '/'
61
+ SILENT_SCRIPT_PREFIX = '-'
62
+ DIV_ID_PREFIX = '#'
63
+ DIV_CLASS_PREFIX = '.'
64
+ FILTER_PREFIX = ':'
65
+ ESCAPE_PREFIX = '\\'
66
+
67
+ def parse_line(line)
68
+ text, indent = @indent_tracker.process(line, @line_parser.lineno)
69
+
70
+ if text.empty?
71
+ @ast << create_node(Ast::Empty)
72
+ return
73
+ end
74
+
75
+ if @ast.is_a?(Ast::HamlComment)
76
+ @ast << create_node(Ast::Text) { |t| t.text = text }
77
+ return
78
+ end
79
+
80
+ case text[0]
81
+ when ESCAPE_PREFIX
82
+ parse_plain(text[1 .. -1])
83
+ when ELEMENT_PREFIX
84
+ parse_element(text)
85
+ when DOCTYPE_PREFIX
86
+ if text.start_with?('!!!')
87
+ parse_doctype(text)
88
+ else
89
+ parse_script(text)
90
+ end
91
+ when COMMENT_PREFIX
92
+ parse_comment(text)
93
+ when SILENT_SCRIPT_PREFIX
94
+ parse_silent_script(text)
95
+ when DIV_ID_PREFIX, DIV_CLASS_PREFIX
96
+ if text.start_with?('#{')
97
+ parse_script(text)
98
+ else
99
+ parse_line("#{indent}%div#{text}")
100
+ end
101
+ when FILTER_PREFIX
102
+ parse_filter(text)
103
+ else
104
+ parse_script(text)
105
+ end
106
+ end
107
+
108
+ def parse_doctype(text)
109
+ @ast << create_node(Ast::Doctype) { |d| d.doctype = text[3 .. -1].strip }
110
+ end
111
+
112
+ def parse_comment(text)
113
+ text = text[1, text.size-1].strip
114
+ comment = create_node(Ast::HtmlComment)
115
+ comment.comment = text
116
+ if text[0] == '['
117
+ comment.conditional, rest = parse_conditional_comment(text)
118
+ text.replace(rest)
119
+ end
120
+ @ast << comment
121
+ end
122
+
123
+ CONDITIONAL_COMMENT_REGEX = /[\[\]]/o
124
+
125
+ def parse_conditional_comment(text)
126
+ s = StringScanner.new(text[1 .. -1])
127
+ depth = Utils.balance(s, '[', ']')
128
+ if depth == 0
129
+ [s.pre_match, s.rest.lstrip]
130
+ else
131
+ syntax_error!('Unmatched brackets in conditional comment')
132
+ end
133
+ end
134
+
135
+ def parse_plain(text)
136
+ @ast << create_node(Ast::Text) { |t| t.text = text }
137
+ end
138
+
139
+ def parse_element(text)
140
+ @ast << ElementParser.new(@line_parser).parse(text)
141
+ end
142
+
143
+ def parse_script(text)
144
+ @ast << ScriptParser.new(@line_parser).parse(text)
145
+ end
146
+
147
+ def parse_silent_script(text)
148
+ if text.start_with?('-#')
149
+ @ast << create_node(Ast::HamlComment)
150
+ return
151
+ end
152
+ node = create_node(Ast::SilentScript)
153
+ script = text[/\A- *(.*)\z/, 1]
154
+ node.script = [script, *RubyMultiline.read(@line_parser, script)].join("\n")
155
+ @ast << node
156
+ end
157
+
158
+ def parse_filter(text)
159
+ filter_name = text[/\A#{FILTER_PREFIX}(\w+)\z/, 1]
160
+ unless filter_name
161
+ syntax_error!("Invalid filter name: #{text}")
162
+ end
163
+ @filter_parser.start(filter_name, @line_parser.filename, @line_parser.lineno)
164
+ end
165
+
166
+ def indent_enter(_, text)
167
+ empty_lines = []
168
+ while @ast.children.last.is_a?(Ast::Empty)
169
+ empty_lines << @ast.children.pop
170
+ end
171
+ @stack.push(@ast)
172
+ @ast = @ast.children.last
173
+ case @ast
174
+ when Ast::Text
175
+ syntax_error!('nesting within plain text is illegal')
176
+ when Ast::Doctype
177
+ syntax_error!('nesting within a header command is illegal')
178
+ end
179
+ @ast.children = empty_lines
180
+ if @ast.is_a?(Ast::Element) && @ast.self_closing
181
+ syntax_error!('Illegal nesting: nesting within a self-closing tag is illegal')
182
+ end
183
+ if @ast.is_a?(Ast::HtmlComment) && !@ast.comment.empty?
184
+ syntax_error!('Illegal nesting: nesting within a html comment that already has content is illegal.')
185
+ end
186
+ if @ast.is_a?(Ast::HamlComment)
187
+ @indent_tracker.enter_comment!
188
+ else
189
+ @indent_tracker.check_indent_level!(@line_parser.lineno)
190
+ end
191
+ nil
192
+ end
193
+
194
+ def indent_leave(indent_level, text)
195
+ parent_ast = @stack.pop
196
+ case @ast
197
+ when Ast::SilentScript
198
+ if indent_level == @indent_tracker.current_level
199
+ @ast.mid_block_keyword = mid_block_keyword?(text)
200
+ end
201
+ end
202
+ @ast = parent_ast
203
+ nil
204
+ end
205
+
206
+ MID_BLOCK_KEYWORDS = %w[else elsif rescue ensure end when]
207
+ START_BLOCK_KEYWORDS = %w[if begin case unless]
208
+ # Try to parse assignments to block starters as best as possible
209
+ START_BLOCK_KEYWORD_REGEX = /(?:\w+(?:,\s*\w+)*\s*=\s*)?(#{Regexp.union(START_BLOCK_KEYWORDS)})/
210
+ BLOCK_KEYWORD_REGEX = /^-?\s*(?:(#{Regexp.union(MID_BLOCK_KEYWORDS)})|#{START_BLOCK_KEYWORD_REGEX.source})\b/
211
+
212
+ def block_keyword(text)
213
+ m = text.match(BLOCK_KEYWORD_REGEX)
214
+ if m
215
+ m[1] || m[2]
216
+ else
217
+ nil
218
+ end
219
+ end
220
+
221
+ def mid_block_keyword?(text)
222
+ MID_BLOCK_KEYWORDS.include?(block_keyword(text))
223
+ end
224
+
225
+ def syntax_error!(message)
226
+ raise Error.new(message, @line_parser.lineno)
227
+ end
228
+
229
+ def create_node(klass, &block)
230
+ klass.new.tap do |node|
231
+ node.filename = @line_parser.filename
232
+ node.lineno = @line_parser.lineno
233
+ if block
234
+ block.call(node)
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end