deadfire 0.1.0 → 0.3.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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ module FrontEnd
5
+ class Parser
6
+ attr_reader :error_reporter, :tokens, :options, :current
7
+
8
+ def initialize(tokens, error_reporter)
9
+ @error_reporter = error_reporter
10
+ @tokens = tokens
11
+ @current = 0
12
+ @stylesheet = StylesheetNode.new
13
+ end
14
+
15
+ def parse
16
+ # top level it's a list of statements
17
+ # statements are either rules or at-rules
18
+ # rules are selectors + declarations
19
+ # at-rules are at-keyword + block
20
+ # block is a list of declarations?
21
+ # declarations are property + value
22
+ while !is_at_end?
23
+ if check(:comment)
24
+ comment = add_comment
25
+ @stylesheet << comment if Deadfire.configuration.keep_comments
26
+ elsif check(:newline)
27
+ newline = add_newline
28
+ @stylesheet << newline if Deadfire.configuration.keep_whitespace
29
+ elsif matches_at_rule?
30
+ @stylesheet << at_rule_declaration
31
+ else
32
+ @stylesheet << ruleset_declaration
33
+ end
34
+ end
35
+ @stylesheet
36
+ end
37
+
38
+ private
39
+
40
+ def is_at_end?
41
+ peek.type == :eof
42
+ end
43
+
44
+ def peek
45
+ tokens[current]
46
+ end
47
+
48
+ def previous
49
+ tokens[current - 1]
50
+ end
51
+
52
+ def advance
53
+ @current += 1 unless is_at_end?
54
+ previous
55
+ end
56
+
57
+ def check(type)
58
+ return false if is_at_end?
59
+
60
+ peek.type == type
61
+ end
62
+
63
+ def match?(*types)
64
+ types.each do |type|
65
+ if check(type)
66
+ advance
67
+ return true
68
+ end
69
+ end
70
+
71
+ false
72
+ end
73
+
74
+ def consume(type, message)
75
+ if check(type)
76
+ advance
77
+ return
78
+ end
79
+
80
+ error_reporter.error(peek.lineno, message)
81
+ end
82
+
83
+ def matches_at_rule?
84
+ check(:at_rule)
85
+ end
86
+
87
+ def matches_nested_rule?
88
+ match?(:ampersand)
89
+ end
90
+
91
+ def parse_block
92
+ block = BlockNode.new
93
+ block << previous
94
+
95
+ while !is_at_end?
96
+ if match?(:right_brace)
97
+ break
98
+ elsif matches_at_rule?
99
+ block << at_rule_declaration
100
+ elsif match?(:left_brace)
101
+ block << parse_block
102
+ else
103
+ block << advance
104
+ end
105
+ end
106
+
107
+ block << previous
108
+ block
109
+ end
110
+
111
+ def ruleset_declaration
112
+ values = []
113
+ while !match?(:left_brace)
114
+ values << advance
115
+ end
116
+
117
+ selector = SelectorNode.new(values[0..-1])
118
+
119
+ block = parse_block
120
+ RulesetNode.new(selector, block)
121
+ end
122
+
123
+ def add_comment
124
+ consume(:comment, "Expect comment")
125
+ CommentNode.new(previous)
126
+ end
127
+
128
+ def add_newline
129
+ consume(:newline, "Expect newline")
130
+ NewlineNode.new(previous.lexeme)
131
+ end
132
+
133
+ def at_rule_declaration
134
+ consume(:at_rule, "Expect at rule")
135
+ keyword = previous
136
+
137
+ # peek until we get to ; or {, if we reach ; then add to at rule node and return
138
+ values = []
139
+ while !match?(:semicolon, :left_brace) && !is_at_end?
140
+ values << advance
141
+ end
142
+
143
+ if previous.type == :semicolon
144
+ if keyword.lexeme == "@apply"
145
+ return ApplyNode.new(keyword, values)
146
+ else
147
+ values << previous # add the semicolon to the values
148
+ return AtRuleNode.new(keyword, values, nil)
149
+ end
150
+ end
151
+
152
+ AtRuleNode.new(keyword, values[0..-1], parse_block) # remove the left brace, because it's not a value, but part of the block
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ module FrontEnd
5
+ class RulesetNode < BaseNode
6
+ attr_reader :selector, :block
7
+
8
+ def initialize(selector, block)
9
+ @selector = selector
10
+ @block = block
11
+ end
12
+
13
+ def accept(visitor)
14
+ visitor.visit_ruleset_node(self)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ module FrontEnd
5
+ class Scanner
6
+ def initialize(source, error_reporter)
7
+ @source = source
8
+ @total_chars = @source.length
9
+ @tokens = []
10
+ @start = 0
11
+ @current = 0
12
+ @line = 1
13
+ @error_reporter = error_reporter
14
+ end
15
+
16
+ def tokenize
17
+ until at_end?
18
+ reset_counter
19
+ scan_token
20
+ end
21
+
22
+ @tokens << Token.new(:eof, "", nil, @line)
23
+ end
24
+
25
+ private
26
+
27
+ NEWLINE = "\n"
28
+
29
+ def reset_counter
30
+ @start = @current - 1
31
+ end
32
+
33
+ def at_end?
34
+ @current >= @total_chars
35
+ end
36
+
37
+ def scan_token
38
+ token = advance
39
+ case token
40
+ when "@" then add_at_rule
41
+ when "{" then add_token(:left_brace)
42
+ when "}" then add_token(:right_brace)
43
+ when "#" then add_token(:id_selector)
44
+ when "." then add_token(:class_selector)
45
+ when ";" then add_token(:semicolon)
46
+ when "," then add_token(:comma)
47
+ when "(" then add_token(:left_paren)
48
+ when ")" then add_token(:right_paren)
49
+ when "_" then add_token(:underscore)
50
+ when "=" then add_token(:equal)
51
+ when "~" then add_token(:tilde)
52
+ when "*" then add_token(:asterisk)
53
+ when "&" then add_token(:ampersand)
54
+ when ":" then add_psuedo_selector
55
+ when "-" then add_hypen_token
56
+ when "/" then add_forward_slash_or_comment
57
+ when "'" then add_token(:single_quote)
58
+ when NEWLINE then add_newline_token
59
+ when " ", "\r", "\t" then add_whitespace_token
60
+ when '"' then add_string_token
61
+ else
62
+ if digit?(token)
63
+ add_number_token
64
+ elsif text?(token)
65
+ add_text_token # or word token?
66
+ else
67
+ add_token(:other)
68
+ end
69
+ end
70
+ end
71
+
72
+ def add_token(type, literal = nil)
73
+ text = @source[@start + 1..current_char_position]
74
+ @tokens << Token.new(type, text, literal, @line)
75
+ end
76
+
77
+ def add_at_rule(literal = nil)
78
+ selector = [current_char]
79
+
80
+ while Spec::CSS_AT_RULES.none? { |kwrd| kwrd == selector.join + peek } && !at_end?
81
+ break if peek == NEWLINE
82
+ selector << advance
83
+ end
84
+
85
+ # final char in at-rule
86
+ selector << advance
87
+
88
+ current_at_rule = selector.join
89
+ at_rule = Spec::CSS_AT_RULES.find { |kwrd| kwrd == current_at_rule }
90
+
91
+ if peek == NEWLINE
92
+ @line += 1
93
+ @error_reporter.error(@line, "at-rule cannot be on multiple lines.")
94
+ add_token(:at_rule, current_at_rule)
95
+ elsif at_rule
96
+ token = add_token(:at_rule, "at_#{at_rule[1..-1]}")
97
+ if at_rule == "@import"
98
+ prescan_import_rule(token.last)
99
+ else
100
+ token
101
+ end
102
+ else
103
+ @error_reporter.error(@line, "Invalid at-rule.")
104
+ end
105
+ end
106
+
107
+ def add_string_token
108
+ while peek != '"' && !at_end?
109
+ @line += 1 if peek == NEWLINE
110
+ advance
111
+ end
112
+
113
+ if at_end?
114
+ @error_reporter.error(@line, "Unterminated string.")
115
+ return
116
+ end
117
+
118
+ advance
119
+
120
+ # Trim the surrounding quotes.
121
+ # TODO: this does not look right... page 50 crafting interpreters.
122
+ value = @source[@start + 2..current_char_position]
123
+ add_token(:string, value)
124
+ end
125
+
126
+ # This token is very similar to the string token, but we want to explicitly
127
+ # split up text from string, because string in css is surrounded by quotes and text is free form
128
+ # which can be a property or value e.g. `color: red;`.
129
+ def add_text_token
130
+ while text?(peek) && !at_end?
131
+ advance
132
+ end
133
+
134
+ value = @source[@start+1..current_char_position]
135
+ add_token(:text, value)
136
+ end
137
+
138
+ def add_number_token
139
+ while digit?(peek)
140
+ advance
141
+ end
142
+
143
+ # Look for a fractional part.
144
+ if peek == "." && digit?(peek_next)
145
+ # Consume the "."
146
+ advance
147
+
148
+ while digit?(peek)
149
+ advance
150
+ end
151
+ end
152
+
153
+ add_token(:number, @source[@start..@current].to_f)
154
+ end
155
+
156
+ def add_psuedo_selector
157
+ if peek == ":"
158
+ advance
159
+ add_token(:double_colon)
160
+ else
161
+ add_token(:colon)
162
+ end
163
+ end
164
+
165
+ def add_hypen_token
166
+ if peek == "-"
167
+ advance
168
+ add_token(:double_hyphen)
169
+ else
170
+ add_token(:hyphen)
171
+ end
172
+ end
173
+
174
+ def add_forward_slash_or_comment
175
+ if peek == "*"
176
+ advance # consume the *
177
+ while peek != "*" && peek_next != "/" && !at_end?
178
+ @line += 1 if peek == NEWLINE
179
+ advance
180
+ end
181
+
182
+ if at_end? && peek != "*"
183
+ @error_reporter.error(@line, "Unterminated comment on line #{@line}.")
184
+ else
185
+ advance # consume the *
186
+ advance # consume the /
187
+ end
188
+ add_token(:comment) # Add the comment anyway, but report an error.
189
+ else
190
+ add_token(:forward_slash)
191
+ end
192
+ end
193
+
194
+ def add_whitespace_token
195
+ add_token(:whitespace) if Deadfire.configuration.keep_whitespace
196
+ end
197
+
198
+ def add_newline_token
199
+ @line += 1
200
+ add_token(:newline) if Deadfire.configuration.keep_whitespace
201
+ end
202
+
203
+ def current_char_position
204
+ @current - 1
205
+ end
206
+
207
+ def current_char
208
+ @source[current_char_position]
209
+ end
210
+
211
+ def advance
212
+ @current += 1
213
+ current_char
214
+ end
215
+
216
+ def peek
217
+ @source[@current] unless at_end?
218
+ end
219
+
220
+ def peek_next
221
+ @source[@current + 1] unless at_end?
222
+ end
223
+
224
+ def digit?(char)
225
+ char >= "0" && char <= "9"
226
+ end
227
+
228
+ def text?(char)
229
+ (char >= "a" && char <= "z") || (char >= "A" && char <= "Z")
230
+ end
231
+
232
+ def prescan_import_rule(token)
233
+ # we want to get all the text between the @import and the semicolon
234
+ # so we can parse the file and add it to the ast
235
+ reset_counter
236
+
237
+ while peek != ";" && !at_end?
238
+ advance
239
+ end
240
+
241
+ if at_end?
242
+ @error_reporter.error(@line, "Unterminated import rule.")
243
+ return
244
+ end
245
+
246
+ add_token(:text)
247
+ advance # remove the semicolon
248
+
249
+ text_token = @tokens.last
250
+
251
+ text = text_token.lexeme.gsub(/\\|"/, '')
252
+ file = FilenameHelper.resolve_import_path(text, @line)
253
+
254
+ # file is ready for scanning
255
+ content = File.read(file)
256
+ scanner = Scanner.new(content, @error_reporter)
257
+
258
+ @tokens.pop # remove the text token
259
+ @tokens.pop # remove the at_rule token
260
+
261
+ imported_tokens = scanner.tokenize[0..-2]
262
+ @tokens.concat imported_tokens
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ module FrontEnd
5
+ class SelectorNode < BaseNode
6
+ attr_reader :selector, :mixin_name
7
+
8
+ def initialize(tokens)
9
+ @selector = tokens_to_selector(tokens)
10
+ @mixin_name = fetch_mixin_name_from(tokens)
11
+ end
12
+
13
+ def accept(visitor)
14
+ visitor.visit_selector_node(self)
15
+ end
16
+
17
+ def cacheable?
18
+ selector.start_with?(".")
19
+ end
20
+
21
+ private
22
+
23
+ # TODO:
24
+ # For descendant values such as `a b`, we need to add a space between the tokens,
25
+ # otherwise all other values will be concatenated together.
26
+ def tokens_to_selector(tokens)
27
+ tokens.map(&:lexeme).join("").strip
28
+ end
29
+
30
+ # https://sass-lang.com/guide
31
+ # https://sass-lang.com/documentation/style-rules/selector
32
+ # TODO: this needs some tests and a lot more work
33
+ # not all selectors are valid mixin names
34
+ def fetch_mixin_name_from(tokens)
35
+ @_cached_mixin_name ||= begin
36
+ name = []
37
+ tokens.each do |token|
38
+ case token.type
39
+ when :right_paren, :left_paren
40
+ break
41
+ when :colon
42
+ name << token.lexeme
43
+ else
44
+ name << token.lexeme
45
+ end
46
+ end
47
+ name.join("")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ module FrontEnd
5
+ class StylesheetNode
6
+ attr_reader :statements
7
+
8
+ def initialize
9
+ @statements = []
10
+ end
11
+
12
+ def accept(visitor)
13
+ visitor.visit_stylesheet_node(self)
14
+ end
15
+
16
+ def << (node)
17
+ @statements << node
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ module FrontEnd
5
+ class Token
6
+ attr_reader :type, :lexeme, :literal, :lineno
7
+
8
+ def initialize(type, lexeme, literal, lineno)
9
+ @type = type
10
+ @lexeme = lexeme
11
+ @literal = literal
12
+ @lineno = lineno
13
+ end
14
+
15
+ def to_s
16
+ "[:#{type}] #{lexeme}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,88 @@
1
+ # Frozen_string_literal: true
2
+
3
+ module Deadfire
4
+ class Interpreter # :nodoc:
5
+ singleton_class.attr_accessor :cached_apply_rules
6
+ self.cached_apply_rules = Hash.new { |h, k| h[k] = nil }
7
+
8
+ def initialize(error_reporter)
9
+ @error_reporter = error_reporter
10
+ end
11
+
12
+ def interpret(node)
13
+ node.accept(self)
14
+ end
15
+
16
+ def visit_stylesheet_node(node)
17
+ node.statements.each { |child| child.accept(self) }
18
+ end
19
+
20
+ def visit_at_rule_node(node)
21
+ if node.block
22
+ visit_block_node(node.block, node)
23
+ end
24
+ end
25
+
26
+ def visit_ruleset_node(node)
27
+ if node.block
28
+ visit_block_node(node.block, node)
29
+
30
+ unless Interpreter.cached_apply_rules[node.selector.selector]
31
+ Interpreter.cached_apply_rules[node.selector.selector] = node.block if node.selector.cacheable?
32
+ end
33
+ end
34
+ end
35
+
36
+ def visit_block_node(node, parent)
37
+ node.declarations.each do |declaration|
38
+ case declaration
39
+ when ApplyNode
40
+ apply_mixin(declaration, node)
41
+ else
42
+ # we may not need to visit this as we don't process/transform/optimize
43
+ end
44
+ end
45
+ end
46
+
47
+ def visit_declaration_node(node)
48
+ node.accept(self)
49
+ end
50
+
51
+ def visit_comment_node(node)
52
+ # node.accept(self)
53
+ end
54
+
55
+ def visit_apply_node(node)
56
+ # do nothing for now
57
+ end
58
+
59
+ def visit_newline_node(node)
60
+ end
61
+
62
+ private
63
+
64
+ def apply_mixin(mixin, node)
65
+ updated_declarations = []
66
+ mixin.mixin_names.each do |mixin_name|
67
+ if Interpreter.cached_apply_rules[mixin_name]
68
+ cached_block = Interpreter.cached_apply_rules[mixin_name]
69
+
70
+ # NOTE: remove the left and right brace but we probably don't need to do this, how can this be simplified?
71
+ cached_block.declarations[1...-1].each do |cached_declaration|
72
+ updated_declarations << cached_declaration
73
+ end
74
+ updated_declarations.shift if updated_declarations.first.type == :newline
75
+ updated_declarations.pop if updated_declarations.last.type == :newline
76
+ else
77
+ @error_reporter.error(mixin.lineno, "Mixin #{mixin_name} not found") # TODO: we need the declarations lineno, not the block
78
+ end
79
+ end
80
+
81
+ if updated_declarations.any?
82
+ index = node.declarations.index(mixin)
83
+ node.declarations.delete_at(index)
84
+ node.declarations.insert(index, *updated_declarations)
85
+ end
86
+ end
87
+ end
88
+ end