haml_parser 0.1.0

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