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.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.travis.yml +10 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +73 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/exe/haml_parser +4 -0
- data/haml_parser.gemspec +29 -0
- data/lib/haml_parser.rb +5 -0
- data/lib/haml_parser/ast.rb +157 -0
- data/lib/haml_parser/cli.rb +44 -0
- data/lib/haml_parser/element_parser.rb +235 -0
- data/lib/haml_parser/error.rb +10 -0
- data/lib/haml_parser/filter_parser.rb +58 -0
- data/lib/haml_parser/indent_tracker.rb +116 -0
- data/lib/haml_parser/line_parser.rb +67 -0
- data/lib/haml_parser/parser.rb +239 -0
- data/lib/haml_parser/ruby_multiline.rb +23 -0
- data/lib/haml_parser/script_parser.rb +107 -0
- data/lib/haml_parser/utils.rb +17 -0
- data/lib/haml_parser/version.rb +3 -0
- metadata +153 -0
@@ -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
|