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,395 @@
1
+ module CSS
2
+ module Selectors
3
+ # Parser for CSS Selectors Level 4. Covers compound and complex
4
+ # selectors, the four standard combinators (descendant, child, next-
5
+ # sibling, subsequent-sibling), pseudo-classes / pseudo-elements
6
+ # (with recursive parsing of `:not/:is/:where/:has` and AnB parsing of
7
+ # `:nth-*`), attribute selectors with case-insensitive `i` / `s` flags,
8
+ # and the `&` nesting selector.
9
+ #
10
+ # Out of scope (intermediate plan): namespace prefixes, the column
11
+ # combinator `||`, and forgiving vs strict selector list distinctions.
12
+ class Parser
13
+ include CSS::TokenCursor
14
+
15
+ # `:has()` is intentionally excluded — it takes a *relative* selector
16
+ # list (each item may start with a combinator) which would require
17
+ # extending the ComplexSelector AST. Falls back to opaque component
18
+ # values for now.
19
+ SELECTOR_LIST_PSEUDOS = %w[is where not matches].freeze
20
+ ANB_PSEUDOS = %w[nth-child nth-last-child nth-of-type nth-last-of-type].freeze
21
+
22
+ ATTR_MATCHERS = {
23
+ '~' => :includes,
24
+ '|' => :dash,
25
+ '^' => :prefix,
26
+ '$' => :suffix,
27
+ '*' => :substring
28
+ }.freeze
29
+
30
+ class << self
31
+ def parse_selector_list(input)
32
+ new(tokens_from(input)).parse_selector_list_complete
33
+ end
34
+
35
+ def parse_selector(input)
36
+ new(tokens_from(input)).parse_selector_complete
37
+ end
38
+
39
+ private
40
+
41
+ def tokens_from(input)
42
+ return Tokenizer.new(input).tokenize if input.is_a?(String)
43
+
44
+ flatten_tokens(input.to_a)
45
+ end
46
+
47
+ # Selectors are normally parsed straight from a token stream, but a
48
+ # caller may pass a `prelude` from the main parser, which contains
49
+ # `Function` and `SimpleBlock` AST nodes in place of the raw paren
50
+ # / bracket tokens. Flatten those back into a token sequence the
51
+ # selector parser can step through.
52
+ def flatten_tokens(items)
53
+ out = []
54
+
55
+ items.each {|it|
56
+ case it
57
+ when Token
58
+ out << it
59
+ when Nodes::SimpleBlock
60
+ out << Token.new(BRACKET_TYPE_FOR_OPEN.fetch(it.open))
61
+ out.concat(flatten_tokens(it.value))
62
+ out << Token.new(BRACKET_CLOSE_TYPE_FOR_OPEN.fetch(it.open))
63
+ when Nodes::Function
64
+ out << Token.new(:function, it.name)
65
+ out.concat(flatten_tokens(it.value))
66
+ out << Token.new(:rparen)
67
+ else
68
+ raise ArgumentError, "cannot feed #{it.class} into selector parser"
69
+ end
70
+ }
71
+
72
+ out
73
+ end
74
+ end
75
+
76
+ BRACKET_TYPE_FOR_OPEN = BRACKET_OPEN_CHAR.invert.freeze
77
+ BRACKET_CLOSE_TYPE_FOR_OPEN = BRACKET_OPEN_CHAR.to_h {|type, ch| [ch, BRACKET_CLOSE_TYPE.fetch(type)] }.freeze
78
+
79
+ def initialize(tokens)
80
+ init_cursor(tokens)
81
+ end
82
+
83
+ def parse_selector_list_complete
84
+ list = parse_selector_list
85
+
86
+ skip_whitespace
87
+
88
+ parse_error!("trailing tokens after selector list: #{peek.type}") unless peek.type == :eof
89
+
90
+ list
91
+ end
92
+
93
+ def parse_selector_complete
94
+ skip_whitespace
95
+
96
+ cs = parse_complex_selector
97
+
98
+ skip_whitespace
99
+
100
+ parse_error!("trailing tokens after selector: #{peek.type}") unless peek.type == :eof
101
+
102
+ cs
103
+ end
104
+
105
+ # A comma-separated list of complex selectors, terminated by EOF or
106
+ # `)` (for use inside functional pseudos like `:is(...)`).
107
+ def parse_selector_list
108
+ skip_whitespace
109
+
110
+ parse_error!('empty selector list') if list_terminator?(peek)
111
+
112
+ selectors = [parse_complex_selector]
113
+
114
+ loop do
115
+ skip_whitespace
116
+ break unless peek.type == :comma
117
+
118
+ consume
119
+ skip_whitespace
120
+ selectors << parse_complex_selector
121
+ end
122
+
123
+ SelectorList.new(selectors:)
124
+ end
125
+
126
+ def parse_complex_selector
127
+ skip_whitespace
128
+
129
+ compounds = [parse_compound_selector]
130
+ combinators = []
131
+
132
+ loop do
133
+ combo = try_consume_combinator
134
+ break if combo.nil?
135
+
136
+ compounds << parse_compound_selector
137
+ combinators << combo
138
+ end
139
+
140
+ ComplexSelector.new(compounds:, combinators:)
141
+ end
142
+
143
+ private
144
+
145
+ def consume_whitespace_returning_bool
146
+ consumed = false
147
+
148
+ while peek.type == :whitespace
149
+ consume
150
+ consumed = true
151
+ end
152
+
153
+ consumed
154
+ end
155
+
156
+ def list_terminator?(t)
157
+ t.type == :eof || t.type == :rparen
158
+ end
159
+
160
+ def try_consume_combinator
161
+ saved = @pos
162
+ had_ws = consume_whitespace_returning_bool
163
+
164
+ t = peek
165
+
166
+ if t.type == :delim && (combo = combinator_for_delim(t.value))
167
+ consume
168
+ skip_whitespace
169
+ return combo
170
+ end
171
+
172
+ if had_ws && compound_selector_ahead?(t)
173
+ return :descendant
174
+ end
175
+
176
+ @pos = saved
177
+ nil
178
+ end
179
+
180
+ def combinator_for_delim(value)
181
+ case value
182
+ when '>' then :child
183
+ when '+' then :next_sibling
184
+ when '~' then :subsequent_sibling
185
+ end
186
+ end
187
+
188
+ def compound_selector_ahead?(t)
189
+ case t.type
190
+ when :ident, :hash, :lbracket, :colon
191
+ true
192
+ when :delim
193
+ %w[* . &].include?(t.value)
194
+ else
195
+ false
196
+ end
197
+ end
198
+
199
+ def parse_compound_selector
200
+ components = []
201
+
202
+ if (head = try_consume_type_or_universal)
203
+ components << head
204
+ end
205
+
206
+ loop do
207
+ sub = try_consume_subclass_or_pseudo
208
+ break if sub.nil?
209
+
210
+ components << sub
211
+ end
212
+
213
+ parse_error!('expected a compound selector') if components.empty?
214
+
215
+ CompoundSelector.new(components:)
216
+ end
217
+
218
+ def try_consume_type_or_universal
219
+ case peek.type
220
+ when :ident
221
+ TypeSelector.new(name: consume.value)
222
+ when :delim
223
+ case peek.value
224
+ when '*' then consume; UniversalSelector.new
225
+ when '&' then consume; NestingSelector.new
226
+ end
227
+ end
228
+ end
229
+
230
+ def try_consume_subclass_or_pseudo
231
+ case peek.type
232
+ when :hash
233
+ parse_id_selector
234
+ when :lbracket
235
+ parse_attribute_selector
236
+ when :colon
237
+ parse_pseudo
238
+ when :delim
239
+ case peek.value
240
+ when '.' then parse_class_selector
241
+ when '&' then consume; NestingSelector.new
242
+ end
243
+ end
244
+ end
245
+
246
+ def parse_id_selector
247
+ t = consume
248
+
249
+ parse_error!('id hash must be a valid identifier') unless t.flag == :id
250
+
251
+ IdSelector.new(name: t.value)
252
+ end
253
+
254
+ def parse_class_selector
255
+ consume # the '.'
256
+
257
+ parse_error!("expected ident after '.', got #{peek.type}") unless peek.type == :ident
258
+
259
+ ClassSelector.new(name: consume.value)
260
+ end
261
+
262
+ def parse_attribute_selector
263
+ consume # [
264
+ skip_whitespace
265
+
266
+ parse_error!('expected attribute name') unless peek.type == :ident
267
+
268
+ name = consume.value
269
+
270
+ skip_whitespace
271
+
272
+ matcher, value = parse_attr_matcher_and_value
273
+ case_flag = parse_attr_case_flag
274
+
275
+ skip_whitespace
276
+
277
+ parse_error!("expected ']', got #{peek.type}") unless peek.type == :rbracket
278
+
279
+ consume
280
+
281
+ AttributeSelector.new(name:, matcher:, value:, case_flag:)
282
+ end
283
+
284
+ def parse_attr_matcher_and_value
285
+ return [nil, nil] if peek.type == :rbracket
286
+
287
+ matcher =
288
+ if peek.type == :delim && peek.value == '='
289
+ consume
290
+ :exact
291
+ elsif peek.type == :delim && (sym = ATTR_MATCHERS[peek.value])
292
+ consume
293
+ unless peek.type == :delim && peek.value == '='
294
+ parse_error!("expected '=' to complete attribute matcher")
295
+ end
296
+ consume
297
+ sym
298
+ else
299
+ parse_error!("invalid attribute matcher: #{peek.type}")
300
+ end
301
+
302
+ skip_whitespace
303
+
304
+ unless peek.type == :ident || peek.type == :string
305
+ parse_error!("expected attribute value, got #{peek.type}")
306
+ end
307
+
308
+ [matcher, consume.value]
309
+ end
310
+
311
+ def parse_attr_case_flag
312
+ skip_whitespace
313
+
314
+ return nil unless peek.type == :ident
315
+
316
+ v = peek.value.downcase
317
+ return nil unless v == 'i' || v == 's'
318
+
319
+ consume
320
+ v.to_sym
321
+ end
322
+
323
+ def parse_pseudo
324
+ consume # first colon
325
+
326
+ if peek.type == :colon
327
+ consume
328
+ parse_pseudo_body(element: true)
329
+ else
330
+ parse_pseudo_body(element: false)
331
+ end
332
+ end
333
+
334
+ def parse_pseudo_body(element:)
335
+ case peek.type
336
+ when :ident
337
+ name = consume.value
338
+ build_pseudo(element:, name:, argument: nil)
339
+ when :function
340
+ name = consume.value
341
+ arg = parse_pseudo_argument(name)
342
+
343
+ parse_error!("expected ')' to close :#{name}") unless peek.type == :rparen
344
+
345
+ consume
346
+ build_pseudo(element:, name:, argument: arg)
347
+ else
348
+ parse_error!("expected pseudo-#{element ? 'element' : 'class'} name, got #{peek.type}")
349
+ end
350
+ end
351
+
352
+ def build_pseudo(element:, name:, argument:)
353
+ element ? PseudoElement.new(name:, argument:) : PseudoClass.new(name:, argument:)
354
+ end
355
+
356
+ def parse_pseudo_argument(name)
357
+ n = name.downcase
358
+
359
+ if SELECTOR_LIST_PSEUDOS.include?(n)
360
+ parse_selector_list
361
+ elsif ANB_PSEUDOS.include?(n)
362
+ AnBParser.parse(collect_argument_tokens)
363
+ else
364
+ collect_argument_tokens
365
+ end
366
+ end
367
+
368
+ # Collects all tokens up to the closing `)` of the current functional
369
+ # context, balancing nested parens / functions.
370
+ def collect_argument_tokens
371
+ inner = []
372
+ depth = 0
373
+
374
+ loop do
375
+ case peek.type
376
+ when :eof
377
+ parse_error!('unexpected EOF in pseudo argument')
378
+ when :function, :lparen
379
+ depth += 1
380
+ inner << consume
381
+ when :rparen
382
+ break if depth.zero?
383
+
384
+ depth -= 1
385
+ inner << consume
386
+ else
387
+ inner << consume
388
+ end
389
+ end
390
+
391
+ inner
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,102 @@
1
+ module CSS
2
+ module Selectors
3
+ module Serializer
4
+ extend self
5
+
6
+ COMBINATOR_GLUE = {
7
+ descendant: ' ',
8
+ child: ' > ',
9
+ next_sibling: ' + ',
10
+ subsequent_sibling: ' ~ '
11
+ }.freeze
12
+
13
+ ATTR_OPS = {
14
+ exact: '=',
15
+ includes: '~=',
16
+ dash: '|=',
17
+ prefix: '^=',
18
+ suffix: '$=',
19
+ substring: '*='
20
+ }.freeze
21
+
22
+ def serialize(node)
23
+ case node
24
+ when SelectorList then node.selectors.map { serialize(it) }.join(', ')
25
+ when ComplexSelector then serialize_complex(node)
26
+ when CompoundSelector then node.components.map { serialize(it) }.join
27
+ when TypeSelector then Escape.ident(node.name)
28
+ when UniversalSelector then '*'
29
+ when NestingSelector then '&'
30
+ when IdSelector then "##{Escape.ident(node.name)}"
31
+ when ClassSelector then ".#{Escape.ident(node.name)}"
32
+ when AttributeSelector then serialize_attribute(node)
33
+ when PseudoClass then serialize_pseudo(node, '')
34
+ when PseudoElement then serialize_pseudo(node, ':')
35
+ when AnB then serialize_anb(node)
36
+ else
37
+ raise ArgumentError, "cannot serialize selector node #{node.class}"
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def serialize_complex(cs)
44
+ out = +serialize(cs.compounds[0])
45
+
46
+ cs.combinators.each_with_index {|combo, i|
47
+ out << COMBINATOR_GLUE.fetch(combo) << serialize(cs.compounds[i + 1])
48
+ }
49
+
50
+ out
51
+ end
52
+
53
+ def serialize_attribute(attr)
54
+ out = +"[#{Escape.ident(attr.name)}"
55
+
56
+ if attr.matcher
57
+ out << ATTR_OPS.fetch(attr.matcher) << Escape.string(attr.value.to_s)
58
+ out << " #{attr.case_flag}" if attr.case_flag
59
+ end
60
+
61
+ out << ']'
62
+ end
63
+
64
+ def serialize_pseudo(node, extra_colon)
65
+ head = ":#{extra_colon}#{Escape.ident(node.name)}"
66
+
67
+ return head if node.argument.nil?
68
+
69
+ "#{head}(#{serialize_argument(node.argument)})"
70
+ end
71
+
72
+ def serialize_argument(arg)
73
+ case arg
74
+ when SelectorList then serialize(arg)
75
+ when AnB then serialize_anb(arg)
76
+ when Array then CSS::Serializer.serialize(arg)
77
+ else
78
+ raise ArgumentError, "unknown pseudo argument #{arg.class}"
79
+ end
80
+ end
81
+
82
+ def serialize_anb(anb)
83
+ return 'even' if anb.step == 2 && anb.offset.zero?
84
+ return 'odd' if anb.step == 2 && anb.offset == 1
85
+
86
+ return anb.offset.to_s if anb.step.zero?
87
+
88
+ step_str =
89
+ case anb.step
90
+ when 1 then 'n'
91
+ when -1 then '-n'
92
+ else "#{anb.step}n"
93
+ end
94
+
95
+ return step_str if anb.offset.zero?
96
+
97
+ sign = anb.offset.positive? ? '+' : '-'
98
+ "#{step_str}#{sign}#{anb.offset.abs}"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,81 @@
1
+ module CSS
2
+ module Selectors
3
+ # Selectors §16 specificity tuple `(a, b, c)`.
4
+ # a = id selectors
5
+ # b = class / attribute / pseudo-class selectors
6
+ # c = type / pseudo-element selectors
7
+ Specificity = Data.define(:a, :b, :c) do
8
+ include Comparable
9
+
10
+ # Avoids the per-call allocation of two 3-element arrays — this
11
+ # comparison runs many times in the cascade-sort hot path.
12
+ def <=>(other)
13
+ return nil unless other.is_a?(Specificity)
14
+
15
+ d = a - other.a
16
+ return d unless d.zero?
17
+
18
+ d = b - other.b
19
+ return d unless d.zero?
20
+
21
+ c - other.c
22
+ end
23
+
24
+ def +(other)
25
+ Specificity.new(a: a + other.a, b: b + other.b, c: c + other.c)
26
+ end
27
+
28
+ def to_s = "#{a},#{b},#{c}"
29
+ end
30
+
31
+ Specificity::ZERO = Specificity.new(a: 0, b: 0, c: 0).freeze
32
+
33
+ # Computes specificity for any selector AST node.
34
+ #
35
+ # Note on the nesting selector (`&`): without parent context its
36
+ # specificity is conservatively reported as zero. Callers wanting
37
+ # accurate cascade behavior should run `CSS.desugar(stylesheet)` first
38
+ # so `&` is replaced by the parent's compounds.
39
+ module SpecificityCalculator
40
+ extend self
41
+
42
+ def calculate(node)
43
+ case node
44
+ when SelectorList then node.selectors.map { calculate(it) }.max || Specificity::ZERO
45
+ when ComplexSelector then sum(node.compounds)
46
+ when CompoundSelector then sum(node.components)
47
+ when IdSelector then Specificity.new(a: 1, b: 0, c: 0)
48
+ when ClassSelector,
49
+ AttributeSelector then Specificity.new(a: 0, b: 1, c: 0)
50
+ when TypeSelector then Specificity.new(a: 0, b: 0, c: 1)
51
+ when PseudoElement then Specificity.new(a: 0, b: 0, c: 1)
52
+ when PseudoClass then specificity_of_pseudo_class(node)
53
+ when UniversalSelector,
54
+ NestingSelector then Specificity::ZERO
55
+ else Specificity::ZERO
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def sum(items)
62
+ items.map { calculate(it) }.reduce(Specificity::ZERO, :+)
63
+ end
64
+
65
+ def specificity_of_pseudo_class(node)
66
+ case node.name.downcase
67
+ when 'where'
68
+ Specificity::ZERO
69
+ when 'is', 'not', 'has', 'matches'
70
+ if node.argument.is_a?(SelectorList)
71
+ calculate(node.argument)
72
+ else
73
+ Specificity.new(a: 0, b: 1, c: 0)
74
+ end
75
+ else
76
+ Specificity.new(a: 0, b: 1, c: 0)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,11 @@
1
+ require_relative 'selectors/nodes'
2
+ require_relative 'selectors/anb_parser'
3
+ require_relative 'selectors/parser'
4
+ require_relative 'selectors/serializer'
5
+ require_relative 'selectors/specificity'
6
+ require_relative 'selectors/matcher'
7
+
8
+ module CSS
9
+ module Selectors
10
+ end
11
+ end