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.
@@ -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