p_css 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/LICENSE.txt +21 -0
- data/README.md +302 -0
- data/lib/css/cascade.rb +168 -0
- data/lib/css/code_points.rb +36 -0
- data/lib/css/escape.rb +82 -0
- data/lib/css/media_queries/context.rb +60 -0
- data/lib/css/media_queries/evaluator.rb +157 -0
- data/lib/css/media_queries/nodes.rb +41 -0
- data/lib/css/media_queries/parser.rb +374 -0
- data/lib/css/media_queries.rb +9 -0
- data/lib/css/nesting.rb +229 -0
- data/lib/css/nodes.rb +42 -0
- data/lib/css/parser.rb +430 -0
- data/lib/css/selectors/anb_parser.rb +174 -0
- data/lib/css/selectors/matcher.rb +449 -0
- data/lib/css/selectors/nodes.rb +61 -0
- data/lib/css/selectors/parser.rb +395 -0
- data/lib/css/selectors/serializer.rb +102 -0
- data/lib/css/selectors/specificity.rb +81 -0
- data/lib/css/selectors.rb +11 -0
- data/lib/css/serializer.rb +167 -0
- data/lib/css/token.rb +78 -0
- data/lib/css/token_cursor.rb +49 -0
- data/lib/css/tokenizer.rb +441 -0
- data/lib/css/urange.rb +45 -0
- data/lib/css/version.rb +3 -0
- data/lib/css.rb +73 -0
- data/lib/p_css.rb +1 -0
- metadata +73 -0
data/lib/css/nesting.rb
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
# Desugars CSS Nesting Module Level 1 — replaces `&` with the parent
|
|
3
|
+
# selector and lifts every nested rule out of its parent. The result is
|
|
4
|
+
# a flat Stylesheet whose rules contain only declarations and at-rules.
|
|
5
|
+
#
|
|
6
|
+
# The substitution favors readable output over conservative correctness
|
|
7
|
+
# padding: `:is(parent)` wrapping is only emitted when the parent has
|
|
8
|
+
# multiple selectors, or when a non-lone `&` is mixed into a compound and
|
|
9
|
+
# the parent has combinators. In simple cases the parent is inlined
|
|
10
|
+
# directly (`& .x` with parent `.a` → `.a .x`, not `:is(.a) .x`).
|
|
11
|
+
module Nesting
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
def desugar(stylesheet)
|
|
15
|
+
Nodes::Stylesheet.new(rules: stylesheet.rules.flat_map { desugar_top_level(it) })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def desugar_top_level(rule)
|
|
21
|
+
case rule
|
|
22
|
+
when Nodes::QualifiedRule
|
|
23
|
+
desugar_qualified_rule(rule, parent_list: nil)
|
|
24
|
+
when Nodes::AtRule
|
|
25
|
+
[desugar_at_rule(rule, parent_list: nil)]
|
|
26
|
+
else
|
|
27
|
+
[rule]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns an Array of rules. The first emitted rule (when there are
|
|
32
|
+
# declarations) carries the parent's own declarations under the
|
|
33
|
+
# effective selector; subsequent rules are the nested ones recursively
|
|
34
|
+
# desugared.
|
|
35
|
+
def desugar_qualified_rule(rule, parent_list:)
|
|
36
|
+
own = Selectors::Parser.parse_selector_list(rule.prelude)
|
|
37
|
+
effective = parent_list ? substitute_nesting(own, parent_list) : own
|
|
38
|
+
|
|
39
|
+
decls, rules = partition_block_items(rule.block.items, parent_list: effective)
|
|
40
|
+
|
|
41
|
+
output = []
|
|
42
|
+
output << build_rule_with(effective, decls) unless decls.empty?
|
|
43
|
+
output.concat(rules)
|
|
44
|
+
output
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Desugars a top-level (or nested) at-rule, recursing into its block
|
|
48
|
+
# if any.
|
|
49
|
+
def desugar_at_rule(rule, parent_list:)
|
|
50
|
+
return rule unless rule.block
|
|
51
|
+
|
|
52
|
+
new_items = desugar_at_rule_block_items(rule.block.items, parent_list:)
|
|
53
|
+
|
|
54
|
+
Nodes::AtRule.new(
|
|
55
|
+
name: rule.name,
|
|
56
|
+
prelude: rule.prelude,
|
|
57
|
+
block: Nodes::Block.new(items: new_items)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Inside an at-rule's block (e.g. `@media`), declarations need to be
|
|
62
|
+
# wrapped in a synthesized rule using the parent selector if there is
|
|
63
|
+
# one (the at-rule itself doesn't carry a selector).
|
|
64
|
+
def desugar_at_rule_block_items(items, parent_list:)
|
|
65
|
+
decls, rules = partition_block_items(items, parent_list:)
|
|
66
|
+
|
|
67
|
+
output = []
|
|
68
|
+
|
|
69
|
+
unless decls.empty?
|
|
70
|
+
if parent_list
|
|
71
|
+
output << build_rule_with(parent_list, decls)
|
|
72
|
+
else
|
|
73
|
+
output.concat(decls)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
output.concat(rules)
|
|
78
|
+
output
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Splits items into (declarations, nested rules). Nested qualified
|
|
82
|
+
# rules and at-rules are recursively desugared.
|
|
83
|
+
def partition_block_items(items, parent_list:)
|
|
84
|
+
decls = []
|
|
85
|
+
rules = []
|
|
86
|
+
|
|
87
|
+
items.each {|item|
|
|
88
|
+
case item
|
|
89
|
+
when Nodes::Declaration
|
|
90
|
+
decls << item
|
|
91
|
+
when Nodes::QualifiedRule
|
|
92
|
+
rules.concat(desugar_qualified_rule(item, parent_list:))
|
|
93
|
+
when Nodes::AtRule
|
|
94
|
+
rules << desugar_at_rule(item, parent_list:)
|
|
95
|
+
else
|
|
96
|
+
decls << item
|
|
97
|
+
end
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
[decls, rules]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_rule_with(selector_list, items)
|
|
104
|
+
Nodes::QualifiedRule.new(
|
|
105
|
+
prelude: Parser.parse_component_values(Selectors::Serializer.serialize(selector_list)),
|
|
106
|
+
block: Nodes::Block.new(items:)
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Substitution
|
|
111
|
+
# ----------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def substitute_nesting(own_list, parent_list)
|
|
114
|
+
Selectors::SelectorList.new(
|
|
115
|
+
selectors: own_list.selectors.map {|sel|
|
|
116
|
+
substitute_complex(ensure_nesting(sel), parent_list)
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Per CSS Nesting §3.1, a selector that doesn't reference `&` has it
|
|
122
|
+
# implicitly prepended with the descendant combinator. `.b` becomes
|
|
123
|
+
# `& .b`.
|
|
124
|
+
def ensure_nesting(complex_selector)
|
|
125
|
+
return complex_selector if contains_nesting?(complex_selector)
|
|
126
|
+
|
|
127
|
+
Selectors::ComplexSelector.new(
|
|
128
|
+
compounds: [
|
|
129
|
+
Selectors::CompoundSelector.new(components: [Selectors::NestingSelector.new]),
|
|
130
|
+
*complex_selector.compounds
|
|
131
|
+
],
|
|
132
|
+
combinators: [:descendant, *complex_selector.combinators]
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def contains_nesting?(complex_selector)
|
|
137
|
+
complex_selector.compounds.any? {|c|
|
|
138
|
+
c.components.any? { it.is_a?(Selectors::NestingSelector) }
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def substitute_complex(own_complex, parent_list)
|
|
143
|
+
if parent_list.selectors.size > 1
|
|
144
|
+
substitute_with_is(own_complex, parent_list)
|
|
145
|
+
else
|
|
146
|
+
parent = parent_list.selectors.first
|
|
147
|
+
|
|
148
|
+
if parent.compounds.size > 1
|
|
149
|
+
substitute_inline_compounds(own_complex, parent, parent_list)
|
|
150
|
+
else
|
|
151
|
+
substitute_inline_components(own_complex, parent.compounds.first)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Multi-selector parent: every `&` becomes `:is(parent_list)`, regardless
|
|
157
|
+
# of the surrounding compound shape.
|
|
158
|
+
def substitute_with_is(own_complex, parent_list)
|
|
159
|
+
replacement = Selectors::PseudoClass.new(name: 'is', argument: parent_list)
|
|
160
|
+
|
|
161
|
+
Selectors::ComplexSelector.new(
|
|
162
|
+
compounds: own_complex.compounds.map { swap_components_in_compound(it, replacement) },
|
|
163
|
+
combinators: own_complex.combinators
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Single complex parent with multiple compounds: a lone `&` compound is
|
|
168
|
+
# spliced in by the parent's compound chain (carrying combinators);
|
|
169
|
+
# mixed compounds use `:is()` wrapping.
|
|
170
|
+
def substitute_inline_compounds(own_complex, parent_complex, parent_list)
|
|
171
|
+
replacement = Selectors::PseudoClass.new(name: 'is', argument: parent_list)
|
|
172
|
+
new_compounds = []
|
|
173
|
+
new_combos = []
|
|
174
|
+
pair_combos = [nil, *own_complex.combinators]
|
|
175
|
+
|
|
176
|
+
own_complex.compounds.each_with_index {|compound, i|
|
|
177
|
+
incoming = pair_combos[i]
|
|
178
|
+
|
|
179
|
+
if lone_nesting?(compound)
|
|
180
|
+
parent_complex.compounds.each_with_index {|pc, j|
|
|
181
|
+
new_compounds << pc
|
|
182
|
+
|
|
183
|
+
if j.zero?
|
|
184
|
+
new_combos << incoming unless incoming.nil?
|
|
185
|
+
else
|
|
186
|
+
new_combos << parent_complex.combinators[j - 1]
|
|
187
|
+
end
|
|
188
|
+
}
|
|
189
|
+
else
|
|
190
|
+
new_compounds << swap_components_in_compound(compound, replacement)
|
|
191
|
+
new_combos << incoming unless incoming.nil?
|
|
192
|
+
end
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
Selectors::ComplexSelector.new(compounds: new_compounds, combinators: new_combos)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Single compound parent: components are spliced in place of `&`,
|
|
199
|
+
# producing a clean compound (`&.x` with parent `.a` → `.a.x`).
|
|
200
|
+
def substitute_inline_components(own_complex, parent_compound)
|
|
201
|
+
Selectors::ComplexSelector.new(
|
|
202
|
+
compounds: own_complex.compounds.map {|compound|
|
|
203
|
+
if lone_nesting?(compound)
|
|
204
|
+
Selectors::CompoundSelector.new(components: parent_compound.components.dup)
|
|
205
|
+
else
|
|
206
|
+
Selectors::CompoundSelector.new(
|
|
207
|
+
components: compound.components.flat_map {|x|
|
|
208
|
+
x.is_a?(Selectors::NestingSelector) ? parent_compound.components : [x]
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
},
|
|
213
|
+
combinators: own_complex.combinators
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def swap_components_in_compound(compound, replacement)
|
|
218
|
+
Selectors::CompoundSelector.new(
|
|
219
|
+
components: compound.components.map {|x|
|
|
220
|
+
x.is_a?(Selectors::NestingSelector) ? replacement : x
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def lone_nesting?(compound)
|
|
226
|
+
compound.components.size == 1 && compound.components.first.is_a?(Selectors::NestingSelector)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
data/lib/css/nodes.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
module Nodes
|
|
3
|
+
# A complete stylesheet: a list of top-level rules.
|
|
4
|
+
Stylesheet = Data.define(:rules)
|
|
5
|
+
|
|
6
|
+
# An at-rule, e.g. `@media (min-width: 600px) { ... }` or `@charset "UTF-8";`.
|
|
7
|
+
# `prelude` is an array of component values, `block` is a Block or nil.
|
|
8
|
+
AtRule = Data.define(:name, :prelude, :block)
|
|
9
|
+
|
|
10
|
+
# A qualified rule, i.e. a style rule. `prelude` is the selector list as
|
|
11
|
+
# raw component values, `block` is the body.
|
|
12
|
+
QualifiedRule = Data.define(:prelude, :block)
|
|
13
|
+
|
|
14
|
+
# The body of a qualified rule or at-rule. Contains a list of declarations,
|
|
15
|
+
# nested qualified rules, and nested at-rules in source order.
|
|
16
|
+
Block = Data.define(:items)
|
|
17
|
+
|
|
18
|
+
# `name: value [!important]`.
|
|
19
|
+
Declaration = Data.define(:name, :value, :important)
|
|
20
|
+
|
|
21
|
+
# A function reference like `rgb(255, 0, 0)`. `value` is an array of
|
|
22
|
+
# component values.
|
|
23
|
+
Function = Data.define(:name, :value)
|
|
24
|
+
|
|
25
|
+
# A `( ... )`, `[ ... ]`, or `{ ... }` block as a component value.
|
|
26
|
+
# `open` is one of `(`, `[`, `{`. `value` is an array of component values.
|
|
27
|
+
SimpleBlock = Data.define(:open, :value) do
|
|
28
|
+
def braced? = open == '{'
|
|
29
|
+
def bracketed? = open == '['
|
|
30
|
+
def parenthesized? = open == '('
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An inclusive code-point range, e.g. `U+0-7F`. Result of CSS.parse_urange.
|
|
34
|
+
UnicodeRange = Data.define(:first, :last) do
|
|
35
|
+
def cover?(cp) = (first..last).cover?(cp)
|
|
36
|
+
|
|
37
|
+
def to_s
|
|
38
|
+
first == last ? format('U+%X', first) : format('U+%X-%X', first, last)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/css/parser.rb
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
# Parser based on CSS Syntax Module Level 4 §5, with the nesting extensions
|
|
3
|
+
# described in CSS Nesting Module Level 1 / Syntax 4.
|
|
4
|
+
# https://drafts.csswg.org/css-syntax/
|
|
5
|
+
class Parser
|
|
6
|
+
include Nodes
|
|
7
|
+
|
|
8
|
+
# Shared sentinel returned past the end of the token stream. It has no
|
|
9
|
+
# position, which is why ParseError messages at EOF show no `line:col:`
|
|
10
|
+
# prefix.
|
|
11
|
+
EOF_TOKEN = Token.new(:eof).freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def parse_stylesheet(input, **opts) = build(input, **opts).parse_stylesheet
|
|
15
|
+
def parse_rule(input, **opts) = build(input, **opts).parse_rule
|
|
16
|
+
def parse_declaration(input, **opts) = build(input, **opts).parse_declaration
|
|
17
|
+
def parse_block_contents(input, **opts) = build(input, **opts).parse_block_contents
|
|
18
|
+
def parse_component_value(input, **opts) = build(input, **opts).parse_component_value
|
|
19
|
+
def parse_component_values(input, **opts) = build(input, **opts).parse_component_values
|
|
20
|
+
def parse_comma_separated_values(input, **opts) = build(input, **opts).parse_comma_separated_values
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build(input, **opts)
|
|
25
|
+
tokens = input.is_a?(String) ? Tokenizer.new(input, **opts).tokenize : input.to_a
|
|
26
|
+
new(tokens)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(tokens)
|
|
31
|
+
@tokens = tokens
|
|
32
|
+
@pos = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# §5.3.3.
|
|
36
|
+
def parse_stylesheet
|
|
37
|
+
Stylesheet.new(rules: consume_rule_list(top_level: true))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# §5.3.4.
|
|
41
|
+
def parse_rule
|
|
42
|
+
skip_whitespace
|
|
43
|
+
|
|
44
|
+
parse_error!('expected a rule, got end of input') if peek.type == :eof
|
|
45
|
+
|
|
46
|
+
rule = if peek.type == :at_keyword
|
|
47
|
+
consume_at_rule(nested: false)
|
|
48
|
+
else
|
|
49
|
+
consume_qualified_rule(nested: false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
parse_error!('invalid rule') if rule.nil?
|
|
53
|
+
|
|
54
|
+
skip_whitespace
|
|
55
|
+
|
|
56
|
+
parse_error!("unexpected token after rule: #{peek.type}") unless peek.type == :eof
|
|
57
|
+
|
|
58
|
+
rule
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# §5.3.5. Per spec, trailing tokens after the declaration are ignored.
|
|
62
|
+
def parse_declaration
|
|
63
|
+
skip_whitespace
|
|
64
|
+
|
|
65
|
+
parse_error!('expected a declaration') unless peek.type == :ident
|
|
66
|
+
|
|
67
|
+
decl = try_consume_declaration
|
|
68
|
+
|
|
69
|
+
parse_error!('invalid declaration') unless decl
|
|
70
|
+
|
|
71
|
+
decl
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# §5.4.4. Used for parsing the contents of a `style="..."` attribute,
|
|
75
|
+
# `@page` blocks, and similar contexts where there is no enclosing `{}`.
|
|
76
|
+
def parse_block_contents
|
|
77
|
+
Block.new(items: collect_block_items(stop_at_close_brace: false))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# §5.3.7.
|
|
81
|
+
def parse_component_value
|
|
82
|
+
skip_whitespace
|
|
83
|
+
|
|
84
|
+
parse_error!('expected a component value') if peek.type == :eof
|
|
85
|
+
|
|
86
|
+
cv = consume_component_value
|
|
87
|
+
|
|
88
|
+
skip_whitespace
|
|
89
|
+
|
|
90
|
+
parse_error!("unexpected token after component value: #{peek.type}") unless peek.type == :eof
|
|
91
|
+
|
|
92
|
+
cv
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# §5.3.8.
|
|
96
|
+
def parse_component_values
|
|
97
|
+
values = []
|
|
98
|
+
values << consume_component_value until peek.type == :eof
|
|
99
|
+
values
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# §5.3.9. Empty input produces `[[]]`; a trailing comma produces a
|
|
103
|
+
# trailing empty group.
|
|
104
|
+
def parse_comma_separated_values
|
|
105
|
+
groups = []
|
|
106
|
+
current = []
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
case peek.type
|
|
110
|
+
when :eof
|
|
111
|
+
groups << current
|
|
112
|
+
return groups
|
|
113
|
+
when :comma
|
|
114
|
+
consume
|
|
115
|
+
groups << current
|
|
116
|
+
current = []
|
|
117
|
+
else
|
|
118
|
+
current << consume_component_value
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def parse_error!(message)
|
|
126
|
+
raise ParseError.new(message, position: peek.position)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def peek(offset = 0)
|
|
130
|
+
@tokens[@pos + offset] || EOF_TOKEN
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def consume
|
|
134
|
+
tok = @tokens[@pos] || EOF_TOKEN
|
|
135
|
+
@pos += 1
|
|
136
|
+
tok
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def reconsume
|
|
140
|
+
@pos -= 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Skips whitespace and (when comments are preserved) comment tokens.
|
|
144
|
+
def skip_whitespace
|
|
145
|
+
consume while peek.trivia?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# CDO/CDC tokens are dropped at the top level (legacy HTML wrapping);
|
|
149
|
+
# inside a block they are treated as the start of a qualified rule.
|
|
150
|
+
# Comment tokens are passed through into the rules list when present.
|
|
151
|
+
def consume_rule_list(top_level:)
|
|
152
|
+
rules = []
|
|
153
|
+
|
|
154
|
+
loop do
|
|
155
|
+
t = peek
|
|
156
|
+
|
|
157
|
+
case t.type
|
|
158
|
+
when :eof
|
|
159
|
+
return rules
|
|
160
|
+
when :whitespace, :semicolon
|
|
161
|
+
consume
|
|
162
|
+
when :comment
|
|
163
|
+
rules << consume
|
|
164
|
+
when :cdo, :cdc
|
|
165
|
+
if top_level
|
|
166
|
+
consume
|
|
167
|
+
else
|
|
168
|
+
rule = consume_qualified_rule(nested: !top_level)
|
|
169
|
+
rules << rule if rule
|
|
170
|
+
end
|
|
171
|
+
when :at_keyword
|
|
172
|
+
rule = consume_at_rule(nested: !top_level)
|
|
173
|
+
rules << rule if rule
|
|
174
|
+
else
|
|
175
|
+
rule = consume_qualified_rule(nested: !top_level)
|
|
176
|
+
rules << rule if rule
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def consume_at_rule(nested:)
|
|
182
|
+
name = consume.value
|
|
183
|
+
prelude = []
|
|
184
|
+
block = nil
|
|
185
|
+
|
|
186
|
+
loop do
|
|
187
|
+
t = peek
|
|
188
|
+
|
|
189
|
+
case t.type
|
|
190
|
+
when :semicolon, :eof
|
|
191
|
+
consume if t.type == :semicolon
|
|
192
|
+
break
|
|
193
|
+
when :rbrace
|
|
194
|
+
break if nested
|
|
195
|
+
|
|
196
|
+
prelude << consume
|
|
197
|
+
when :lbrace
|
|
198
|
+
consume
|
|
199
|
+
block = consume_braced_block
|
|
200
|
+
break
|
|
201
|
+
else
|
|
202
|
+
prelude << consume_component_value
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
strip_whitespace!(prelude)
|
|
207
|
+
AtRule.new(name:, prelude:, block:)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def consume_qualified_rule(nested:)
|
|
211
|
+
saved = @pos
|
|
212
|
+
prelude = []
|
|
213
|
+
|
|
214
|
+
loop do
|
|
215
|
+
t = peek
|
|
216
|
+
|
|
217
|
+
case t.type
|
|
218
|
+
when :eof
|
|
219
|
+
@pos = saved
|
|
220
|
+
return nil
|
|
221
|
+
when :rbrace
|
|
222
|
+
if nested
|
|
223
|
+
@pos = saved
|
|
224
|
+
return nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
prelude << consume
|
|
228
|
+
when :semicolon
|
|
229
|
+
if nested
|
|
230
|
+
consume
|
|
231
|
+
return nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
prelude << consume
|
|
235
|
+
when :lbrace
|
|
236
|
+
consume
|
|
237
|
+
block = consume_braced_block
|
|
238
|
+
strip_whitespace!(prelude)
|
|
239
|
+
return QualifiedRule.new(prelude:, block:)
|
|
240
|
+
else
|
|
241
|
+
prelude << consume_component_value
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Assumes the opening `{` has already been consumed; consumes through
|
|
247
|
+
# the matching `}`.
|
|
248
|
+
def consume_braced_block
|
|
249
|
+
Block.new(items: collect_block_items(stop_at_close_brace: true))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Shared loop for both `{}`-terminated blocks (inside a rule) and
|
|
253
|
+
# EOF-terminated block contents (style attribute, `@page`, etc.).
|
|
254
|
+
# Comment tokens are passed through into the items list when present.
|
|
255
|
+
def collect_block_items(stop_at_close_brace:)
|
|
256
|
+
items = []
|
|
257
|
+
|
|
258
|
+
loop do
|
|
259
|
+
t = peek
|
|
260
|
+
|
|
261
|
+
case t.type
|
|
262
|
+
when :eof
|
|
263
|
+
return items
|
|
264
|
+
when :rbrace
|
|
265
|
+
consume
|
|
266
|
+
return items if stop_at_close_brace
|
|
267
|
+
# Stray `}` outside any block: parse error per spec; skip.
|
|
268
|
+
next
|
|
269
|
+
when :whitespace, :semicolon
|
|
270
|
+
consume
|
|
271
|
+
when :comment
|
|
272
|
+
items << consume
|
|
273
|
+
when :at_keyword
|
|
274
|
+
rule = consume_at_rule(nested: true)
|
|
275
|
+
items << rule if rule
|
|
276
|
+
else
|
|
277
|
+
decl = try_consume_declaration
|
|
278
|
+
if decl
|
|
279
|
+
items << decl
|
|
280
|
+
else
|
|
281
|
+
rule = consume_qualified_rule(nested: true)
|
|
282
|
+
items << rule if rule
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# In nested context a token sequence like `a:hover { ... }` looks like
|
|
289
|
+
# the start of a declaration (`<ident> : ...`). We detect such cases by
|
|
290
|
+
# noticing a `{}` simple block in the value and rolling back so the
|
|
291
|
+
# caller can re-parse it as a nested qualified rule.
|
|
292
|
+
def try_consume_declaration
|
|
293
|
+
saved = @pos
|
|
294
|
+
|
|
295
|
+
return nil unless peek.type == :ident
|
|
296
|
+
|
|
297
|
+
name = consume.value
|
|
298
|
+
|
|
299
|
+
skip_whitespace
|
|
300
|
+
|
|
301
|
+
unless peek.type == :colon
|
|
302
|
+
@pos = saved
|
|
303
|
+
return nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
consume
|
|
307
|
+
|
|
308
|
+
value = []
|
|
309
|
+
|
|
310
|
+
loop do
|
|
311
|
+
t = peek
|
|
312
|
+
|
|
313
|
+
case t.type
|
|
314
|
+
when :semicolon
|
|
315
|
+
consume
|
|
316
|
+
break
|
|
317
|
+
when :eof, :rbrace
|
|
318
|
+
break
|
|
319
|
+
else
|
|
320
|
+
value << consume_component_value
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
if value.any? { it.is_a?(SimpleBlock) && it.braced? }
|
|
325
|
+
@pos = saved
|
|
326
|
+
return nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
important = extract_important!(value)
|
|
330
|
+
strip_whitespace!(value)
|
|
331
|
+
|
|
332
|
+
Declaration.new(name:, value:, important:)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def consume_component_value
|
|
336
|
+
t = peek
|
|
337
|
+
|
|
338
|
+
case t.type
|
|
339
|
+
when :lbrace, :lbracket, :lparen
|
|
340
|
+
consume_simple_block
|
|
341
|
+
when :function
|
|
342
|
+
consume_function
|
|
343
|
+
else
|
|
344
|
+
consume
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def consume_simple_block
|
|
349
|
+
open_type = consume.type
|
|
350
|
+
open_char = BRACKET_OPEN_CHAR.fetch(open_type)
|
|
351
|
+
end_type = BRACKET_CLOSE_TYPE.fetch(open_type)
|
|
352
|
+
values = []
|
|
353
|
+
|
|
354
|
+
loop do
|
|
355
|
+
t = peek
|
|
356
|
+
|
|
357
|
+
case t.type
|
|
358
|
+
when :eof
|
|
359
|
+
break
|
|
360
|
+
when end_type
|
|
361
|
+
consume
|
|
362
|
+
break
|
|
363
|
+
else
|
|
364
|
+
values << consume_component_value
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
SimpleBlock.new(open: open_char, value: values)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def consume_function
|
|
372
|
+
name = consume.value
|
|
373
|
+
values = []
|
|
374
|
+
|
|
375
|
+
loop do
|
|
376
|
+
t = peek
|
|
377
|
+
|
|
378
|
+
case t.type
|
|
379
|
+
when :eof
|
|
380
|
+
break
|
|
381
|
+
when :rparen
|
|
382
|
+
consume
|
|
383
|
+
break
|
|
384
|
+
else
|
|
385
|
+
values << consume_component_value
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
Function.new(name:, value: values)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Strips only whitespace tokens — comments, when present, are preserved
|
|
393
|
+
# as significant content of preludes/values.
|
|
394
|
+
def strip_whitespace!(value)
|
|
395
|
+
value.shift while whitespace_item?(value.first)
|
|
396
|
+
value.pop while whitespace_item?(value.last)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def whitespace_item?(item)
|
|
400
|
+
item.is_a?(Token) && item.whitespace?
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Strips a trailing `! important` from `value` and returns whether it
|
|
404
|
+
# was present.
|
|
405
|
+
def extract_important!(value)
|
|
406
|
+
i = value.length - 1
|
|
407
|
+
i -= 1 while i >= 0 && trivia_item?(value[i])
|
|
408
|
+
|
|
409
|
+
return false unless i >= 1
|
|
410
|
+
|
|
411
|
+
ident = value[i]
|
|
412
|
+
|
|
413
|
+
return false unless ident.is_a?(Token) && ident.type == :ident && ident.value.casecmp('important').zero?
|
|
414
|
+
|
|
415
|
+
j = i - 1
|
|
416
|
+
j -= 1 while j >= 0 && trivia_item?(value[j])
|
|
417
|
+
|
|
418
|
+
bang = value[j]
|
|
419
|
+
|
|
420
|
+
return false unless bang.is_a?(Token) && bang.type == :delim && bang.value == '!'
|
|
421
|
+
|
|
422
|
+
value.slice!(j..)
|
|
423
|
+
true
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def trivia_item?(item)
|
|
427
|
+
item.is_a?(Token) && item.trivia?
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|