p_css 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 223962d85169a337ff087e759a5bce186ece7182cd15ead418c280228fdde208
4
- data.tar.gz: 4ccd0ca9e312dec47fc8f41f4d0b02d6918f86c47279d463f1c6c3e2e75549b8
3
+ metadata.gz: caf08474039a751417ef1a6469b5e115206a0d7347234f13b703cf23c02e687f
4
+ data.tar.gz: 6f2f93498e29789e8d2c95f97b307835c5e4706d87af5b88056213272c650f96
5
5
  SHA512:
6
- metadata.gz: 9a4e563505c86e0939c03410c1d75be48b5fc1e9b9ada13dc422ee0b290dba215a94d4cd5dced0c2c4984cd15770bd2797b667436b77709766e7c9d64b750280
7
- data.tar.gz: dae2b3b18a78f04eb9dc9babd31e0b1f7e17102740f2fd4cfe34112b24095c8d2c1453e2e67ffb0cd91949d31c35ea4ef42d71c620d8cc0c86cfb67632592f18
6
+ metadata.gz: fff681ce658d521a7335492339f3e5a6510eabe7f69698af6ed33ff4f85d0e52901d21f2717efc574605a663cfdd2a29a5bbc74a7ef261fa1766c53b3fe1e4fc
7
+ data.tar.gz: 284c266752d7655da99c9a305c8555c5003f0316befc72b59b3da1e0e480c40e17b0e00440e28c9527ddee665a8a6ad8be9a74596d9e83319932e6724bb96ed3
data/README.md CHANGED
@@ -40,6 +40,7 @@ is hardwired in.
40
40
  | AnB microsyntax | `CSS.parse_anb` | Syntax 4 §6.7 |
41
41
  | Specificity | `CSS.specificity` | Selectors §16 |
42
42
  | Selector matcher | `CSS.matches?` | Selectors 4 |
43
+ | Tree queries | `CSS.select_all`, `CSS.select_first`, `CSS.closest` | Selectors 4 |
43
44
  | Nesting de-sugar | `CSS.desugar` | Nesting 1 |
44
45
  | Media query parser | `CSS.parse_media_query_list` | Media Queries 4 |
45
46
  | Media query evaluator | `CSS.media_matches?` | Media Queries 4 |
@@ -134,7 +135,7 @@ attr.matcher # => :exact
134
135
  attr.case_flag # => :i
135
136
 
136
137
  nth = compound.components[1]
137
- nth.argument # => CSS::Selectors::AnB(step: 2, offset: 1)
138
+ nth.argument # => CSS::Selectors::AnB(step: 2, offset: 1, of: nil)
138
139
  ```
139
140
 
140
141
  The selector parser also accepts the prelude of a parsed rule directly (the
@@ -179,13 +180,38 @@ active = doc.at_css('li.active')
179
180
  CSS.matches?(active, 'li:nth-child(2n)') # => true
180
181
  CSS.matches?(active, ':is(.active, .selected)') # => true
181
182
  CSS.matches?(active, 'ul > li:not(:first-child)') # => true
183
+ CSS.matches?(active, 'li:nth-child(1 of .active)') # => true
184
+ CSS.matches?(doc.at_css('ul'), 'ul:has(> .active)') # => true
182
185
  ```
183
186
 
184
- Stateful pseudo-classes (`:hover`, `:focus`, `:visited`, validity API states,
185
- etc.) return `false` by default there's no UA in the loop. Pass a `state:`
186
- Hash to opt in; see [Stateful pseudo-classes](#stateful-pseudo-classes)
187
- below. `:has()` is not yet implemented (its argument is kept as opaque
188
- component values).
187
+ Supported Selectors-4 features include `:has()` (relative selector list),
188
+ `:nth-child(An+B of S)`, and namespace prefixes (`*|name`, `|name`; a declared
189
+ prefix is rejected there is no `@namespace` mechanism). `:empty` follows
190
+ Selectors-4 (whitespace-only content is `:empty`); pass
191
+ `empty_allows_whitespace: false` for the real-browser / Selectors-3 behaviour.
192
+
193
+ Stateful pseudo-classes (`:hover`, `:focus`, `:visited`, and the
194
+ constraint-validation states `:valid` / `:invalid` / `:user-valid` /
195
+ `:user-invalid` / `:indeterminate`) return `false` by default — there's no UA
196
+ in the loop. Pass a `state:` Hash to opt in; see
197
+ [Stateful pseudo-classes](#stateful-pseudo-classes) below.
198
+
199
+ #### Tree queries and `:scope`
200
+
201
+ `select_all` / `select_first` walk a root's descendants (document order);
202
+ `closest` walks inclusive ancestors. `:scope` matches the elements passed via
203
+ `scope:` (defaulting to `:root`):
204
+
205
+ ```ruby
206
+ row = doc.at_css('ul')
207
+
208
+ CSS.select_all(row, '.active') # => [<li class="active">]
209
+ CSS.select_first(row, 'li') # => <li>one</li>
210
+ CSS.closest(active, 'ul') # => <ul>
211
+
212
+ # `:scope` resolves against the supplied scoping element.
213
+ CSS.select_all(row, ':scope > li', scope: row) # => the three <li>s
214
+ ```
189
215
 
190
216
  ### Nesting de-sugar
191
217
 
@@ -271,8 +297,9 @@ encapsulation are not modeled — `@layer` / `@supports` / `@scope` /
271
297
  ### Stateful pseudo-classes
272
298
 
273
299
  `:hover`, `:focus`, `:focus-within`, `:focus-visible`, `:active`, `:visited`,
274
- and `:target` return `false` from the matcher by default. Pass a `state:`
275
- Hash to override:
300
+ `:target`, and the constraint-validation states (`:valid`, `:invalid`,
301
+ `:user-valid`, `:user-invalid`, `:indeterminate`) return `false` from the
302
+ matcher by default. Pass a `state:` Hash to override:
276
303
 
277
304
  ```ruby
278
305
  state = {
@@ -336,9 +363,9 @@ r.to_s # => "U+1000-10FF"
336
363
 
337
364
  These are deliberate omissions; pull requests welcome:
338
365
 
339
- - Selectors Level 4 namespace prefixes (`ns|*`)
366
+ - Declared namespace prefixes / `@namespace` (only `*|name` and `|name` are
367
+ supported; a declared prefix like `svg|rect` is rejected)
340
368
  - The column combinator `||`
341
- - `:has()` (relative selector list — needs a small AST extension)
342
369
  - Strict/forgiving selector list distinction
343
370
  - `@scope` proximity and the rest of the Cascade Layers spec
344
371
  - Layout calculations (`display: block` vs flex sizing, `overflow: hidden`
data/lib/css/nesting.rb CHANGED
@@ -33,7 +33,16 @@ module CSS
33
33
  # effective selector; subsequent rules are the nested ones recursively
34
34
  # desugared.
35
35
  def desugar_qualified_rule(rule, parent_list:)
36
- own = Selectors::Parser.parse_selector_list(rule.prelude)
36
+ # Nested rules follow the <relative-selector-list> grammar (a selector
37
+ # may begin with a combinator); top-level rules keep the strict parser,
38
+ # so a top-level leading combinator is still a syntax error.
39
+ own =
40
+ if parent_list
41
+ Selectors::Parser.parse_nesting_selector_list(rule.prelude)
42
+ else
43
+ Selectors::Parser.parse_selector_list(rule.prelude)
44
+ end
45
+
37
46
  effective = parent_list ? substitute_nesting(own, parent_list) : own
38
47
 
39
48
  decls, rules = partition_block_items(rule.block.items, parent_list: effective)
@@ -17,8 +17,9 @@ module CSS
17
17
  #
18
18
  # Pseudo-classes that depend on user-agent state (`:hover`, `:focus`,
19
19
  # `:visited`, etc.) return false by default; pass an explicit `state:`
20
- # mapping to opt into stateful matching. Validity-API and viewport-
21
- # only states (`:fullscreen`, `:valid`, ) are not exposed.
20
+ # mapping to opt into stateful matching. Constraint-validation states
21
+ # (`:valid`, `:invalid`, `:user-valid`, `:user-invalid`, `:indeterminate`)
22
+ # can't be derived from the DOM, so they are also `state:` opt-ins.
22
23
  module Matcher
23
24
  extend self
24
25
 
@@ -30,7 +31,10 @@ module CSS
30
31
  # User-agent state pseudos. The matcher returns `false` for these
31
32
  # unless the caller passes a `state:` Hash describing which
32
33
  # elements (or "all") should match.
33
- STATEFUL_PSEUDOS = %w[hover focus focus-within focus-visible active visited target].to_set.freeze
34
+ STATEFUL_PSEUDOS = %w[
35
+ hover focus focus-within focus-visible active visited target
36
+ valid invalid user-valid user-invalid indeterminate
37
+ ].to_set.freeze
34
38
 
35
39
  # Per spec these states propagate up the ancestor chain — if a
36
40
  # descendant is hovered/active/contains-focus, the ancestors
@@ -45,8 +49,24 @@ module CSS
45
49
 
46
50
  EMPTY_CLASSES = [].freeze
47
51
 
48
- def matches?(element, selector, cache: nil, state: nil)
49
- sel = selector.is_a?(String) ? Parser.parse_selector_list(selector) : selector
52
+ # Private key under which the `:scope` set is carried on the `state`
53
+ # object, so it threads through the deep match recursion without changing
54
+ # every method signature. A user `state` Hash is keyed by pseudo names,
55
+ # so this unique object never collides.
56
+ SCOPE_KEY = Object.new.freeze
57
+
58
+ # Private key carrying the `:empty` whitespace policy on `state`. Only
59
+ # stamped when the caller opts out of the default (whitespace allowed),
60
+ # so the key's absence means "Selectors-4 default".
61
+ EMPTY_WS_KEY = Object.new.freeze
62
+
63
+ # `scope` (an element, Array, or Set) is the set `:scope` matches; with
64
+ # none, `:scope` falls back to `:root`. `empty_allows_whitespace`
65
+ # (default true, Selectors-4) controls `:empty`: when false, any
66
+ # non-empty text — including whitespace — disqualifies (real browsers).
67
+ def matches?(element, selector, cache: nil, state: nil, scope: nil, empty_allows_whitespace: true)
68
+ sel = selector.is_a?(String) ? Parser.parse_selector_list(selector) : selector
69
+ state = build_state(state, scope, empty_allows_whitespace)
50
70
 
51
71
  case sel
52
72
  when SelectorList
@@ -60,8 +80,100 @@ module CSS
60
80
  end
61
81
  end
62
82
 
83
+ # querySelectorAll-style: every descendant of `roots` (an element or
84
+ # array of elements), in document order, matching `selector`. `:scope`
85
+ # honours the `scope:` option (default `:root`).
86
+ def select_all(roots, selector, state: nil, scope: nil, empty_allows_whitespace: true)
87
+ sel = selector.is_a?(String) ? Parser.parse_selector_list(selector) : selector
88
+ state = build_state(state, scope, empty_allows_whitespace)
89
+ cache = {}
90
+ list = roots.is_a?(Array) ? roots : [roots]
91
+ out = []
92
+
93
+ list.each { collect_matches(_1, sel, cache, state, out) }
94
+
95
+ list.size > 1 ? dedup_nodes(out) : out
96
+ end
97
+
98
+ def select_first(roots, selector, state: nil, scope: nil, empty_allows_whitespace: true)
99
+ sel = selector.is_a?(String) ? Parser.parse_selector_list(selector) : selector
100
+ state = build_state(state, scope, empty_allows_whitespace)
101
+ cache = {}
102
+ list = roots.is_a?(Array) ? roots : [roots]
103
+
104
+ list.each do |root|
105
+ hit = first_match(root, sel, cache, state)
106
+ return hit if hit
107
+ end
108
+
109
+ nil
110
+ end
111
+
112
+ # Nearest inclusive-ancestor of `element` matching `selector`
113
+ # (Element#closest).
114
+ def closest(element, selector, state: nil, scope: nil, empty_allows_whitespace: true)
115
+ sel = selector.is_a?(String) ? Parser.parse_selector_list(selector) : selector
116
+ state = build_state(state, scope, empty_allows_whitespace)
117
+ cache = {}
118
+ cur = element
119
+
120
+ while cur
121
+ return cur if matches?(cur, sel, cache: cache, state: state)
122
+
123
+ cur = parent_element(cur)
124
+ end
125
+
126
+ nil
127
+ end
128
+
63
129
  private
64
130
 
131
+ # Folds the `scope:` set and the `:empty` whitespace policy onto the
132
+ # user `state` Hash, so both thread through the match recursion. A no-op
133
+ # at the defaults (no scope, whitespace allowed), so recursive matches?
134
+ # calls — which always pass the defaults — leave a carried flag intact.
135
+ def build_state(state, scope, empty_allows_whitespace)
136
+ state = with_scope(state, scope) unless scope.nil?
137
+ state = (state || {}).merge(EMPTY_WS_KEY => false) unless empty_allows_whitespace
138
+ state
139
+ end
140
+
141
+ def with_scope(state, scope)
142
+ set =
143
+ case scope
144
+ when Set then scope.to_a
145
+ when Array then scope
146
+ else [scope]
147
+ end
148
+
149
+ (state || {}).merge(SCOPE_KEY => set)
150
+ end
151
+
152
+ def collect_matches(node, sel, cache, state, out)
153
+ element_children(node).each do |child|
154
+ out << child if matches?(child, sel, cache: cache, state: state)
155
+
156
+ collect_matches(child, sel, cache, state, out)
157
+ end
158
+ end
159
+
160
+ def first_match(node, sel, cache, state)
161
+ element_children(node).each do |child|
162
+ return child if matches?(child, sel, cache: cache, state: state)
163
+
164
+ found = first_match(child, sel, cache, state)
165
+ return found if found
166
+ end
167
+
168
+ nil
169
+ end
170
+
171
+ def dedup_nodes(nodes)
172
+ result = []
173
+ nodes.each {|n| result << n unless result.any? { same_node?(_1, n) } }
174
+ result
175
+ end
176
+
65
177
  # Walks the complex selector right-to-left starting at the rightmost
66
178
  # compound. Each combinator either succeeds against ancestors /
67
179
  # siblings of the current candidate or fails the whole match.
@@ -114,8 +226,8 @@ module CSS
114
226
 
115
227
  def match_simple(element, simple, cache, state)
116
228
  case simple
117
- when TypeSelector then tag_of(element, cache).casecmp?(simple.name)
118
- when UniversalSelector then true
229
+ when TypeSelector then match_type(element, simple, cache)
230
+ when UniversalSelector then simple.namespace == '' ? in_no_namespace?(element) : true
119
231
  when IdSelector then id_of(element, cache) == simple.name
120
232
  when ClassSelector then classes_of(element, cache).include?(simple.name)
121
233
  when AttributeSelector then match_attribute(element, simple)
@@ -169,6 +281,25 @@ module CSS
169
281
  v.to_s.split(' ')
170
282
  end
171
283
 
284
+ # Type matching ---------------------------------------------------
285
+
286
+ # A bare `name` / `*|name` matches by local name in any namespace; a
287
+ # `|name` (namespace `''`) additionally requires the element to be in no
288
+ # namespace. The no-namespace constraint is enforced only when the
289
+ # element exposes namespace info (`respond_to?(:namespace)`); otherwise
290
+ # it is skipped and matching is by local name alone.
291
+ def match_type(element, sel, cache)
292
+ return false unless tag_of(element, cache).casecmp?(sel.name)
293
+
294
+ sel.namespace == '' ? in_no_namespace?(element) : true
295
+ end
296
+
297
+ def in_no_namespace?(element)
298
+ return true unless element.respond_to?(:namespace)
299
+
300
+ element.namespace.nil?
301
+ end
302
+
172
303
  # Attribute matching ----------------------------------------------
173
304
 
174
305
  def match_attribute(element, attr_sel)
@@ -205,20 +336,20 @@ module CSS
205
336
  case name
206
337
  when 'is', 'where', 'matches' then match_selector_list_arg(element, pc.argument, cache, state)
207
338
  when 'not' then negate_selector_list_arg(element, pc.argument, cache, state)
208
- when 'has' then false
339
+ when 'has' then match_has(element, pc.argument, cache, state)
209
340
  when 'root' then parent_element(element).nil?
210
- when 'scope' then parent_element(element).nil?
341
+ when 'scope' then match_scope(element, state)
211
342
  when 'first-child' then previous_element(element).nil?
212
343
  when 'last-child' then next_element(element).nil?
213
344
  when 'only-child' then previous_element(element).nil? && next_element(element).nil?
214
345
  when 'first-of-type' then same_type_previous(element).nil?
215
346
  when 'last-of-type' then same_type_next(element).nil?
216
347
  when 'only-of-type' then same_type_previous(element).nil? && same_type_next(element).nil?
217
- when 'nth-child' then match_nth(element, pc.argument, of_type: false, from_end: false)
218
- when 'nth-last-child' then match_nth(element, pc.argument, of_type: false, from_end: true)
219
- when 'nth-of-type' then match_nth(element, pc.argument, of_type: true, from_end: false)
220
- when 'nth-last-of-type' then match_nth(element, pc.argument, of_type: true, from_end: true)
221
- when 'empty' then empty?(element)
348
+ when 'nth-child' then match_nth(element, pc.argument, of_type: false, from_end: false, cache:, state:)
349
+ when 'nth-last-child' then match_nth(element, pc.argument, of_type: false, from_end: true, cache:, state:)
350
+ when 'nth-of-type' then match_nth(element, pc.argument, of_type: true, from_end: false, cache:, state:)
351
+ when 'nth-last-of-type' then match_nth(element, pc.argument, of_type: true, from_end: true, cache:, state:)
352
+ when 'empty' then empty?(element, empty_allows_whitespace?(state))
222
353
  when 'link', 'any-link' then link?(element)
223
354
  when 'enabled' then disableable?(element) && !disabled?(element)
224
355
  when 'disabled' then disabled?(element)
@@ -228,6 +359,7 @@ module CSS
228
359
  when 'read-only' then read_only?(element)
229
360
  when 'read-write' then read_write?(element)
230
361
  when 'placeholder-shown' then placeholder_shown?(element)
362
+ when 'default' then default?(element)
231
363
  when 'lang' then match_lang(element, pc.argument)
232
364
  when 'dir' then match_dir(element, pc.argument)
233
365
  when 'defined' then true
@@ -271,10 +403,106 @@ module CSS
271
403
  arg.is_a?(SelectorList) && !matches?(element, arg, cache: cache, state: state)
272
404
  end
273
405
 
274
- def match_nth(element, anb, of_type:, from_end:)
406
+ # `:scope` matches the scoping roots supplied via `scope:` (carried on
407
+ # `state` under SCOPE_KEY); with none it falls back to `:root`.
408
+ def match_scope(element, state)
409
+ set = state && state[SCOPE_KEY]
410
+
411
+ set ? set.any? { same_node?(_1, element) } : parent_element(element).nil?
412
+ end
413
+
414
+ # `:has(...)` — searches the anchor's subtree (descendant / child) or its
415
+ # following siblings (`+` / `~`) for a subject whose right-to-left match
416
+ # chains back to the anchor via the relative selector's leading
417
+ # combinator. Each relative selector is tried independently.
418
+ def match_has(anchor, argument, cache, state)
419
+ return false unless argument.is_a?(RelativeSelectorList)
420
+
421
+ argument.selectors.any? { has_relative?(anchor, _1, cache, state) }
422
+ end
423
+
424
+ def has_relative?(anchor, rel, cache, state)
425
+ return has_in_subtree?(anchor, rel, anchor, cache, state) unless sibling_leading?(rel.combinator)
426
+
427
+ sib = next_element(anchor)
428
+
429
+ while sib
430
+ return true if rel_at?(sib, rel, rel.complex.compounds.size - 1, anchor, cache, state)
431
+ return true if has_in_subtree?(sib, rel, anchor, cache, state)
432
+
433
+ sib = next_element(sib)
434
+ end
435
+
436
+ false
437
+ end
438
+
439
+ def has_in_subtree?(node, rel, anchor, cache, state)
440
+ element_children(node).each do |child|
441
+ return true if rel_at?(child, rel, rel.complex.compounds.size - 1, anchor, cache, state)
442
+ return true if has_in_subtree?(child, rel, anchor, cache, state)
443
+ end
444
+
445
+ false
446
+ end
447
+
448
+ # Matches the relative selector's compound at `index` against `element`,
449
+ # then walks the remaining compounds right-to-left; at index 0 the chain
450
+ # must connect back to the anchor via the leading combinator.
451
+ def rel_at?(element, rel, index, anchor, cache, state)
452
+ return false if element.nil?
453
+ return false unless match_compound(element, rel.complex.compounds[index], cache, state)
454
+ return connects_to_anchor?(element, rel.combinator, anchor) if index.zero?
455
+
456
+ case rel.complex.combinators[index - 1]
457
+ when :child
458
+ rel_at?(parent_element(element), rel, index - 1, anchor, cache, state)
459
+ when :descendant
460
+ a = parent_element(element)
461
+ a = parent_element(a) until a.nil? || rel_at?(a, rel, index - 1, anchor, cache, state)
462
+ !a.nil?
463
+ when :next_sibling
464
+ rel_at?(previous_element(element), rel, index - 1, anchor, cache, state)
465
+ when :subsequent_sibling
466
+ s = previous_element(element)
467
+ s = previous_element(s) until s.nil? || rel_at?(s, rel, index - 1, anchor, cache, state)
468
+ !s.nil?
469
+ end
470
+ end
471
+
472
+ def connects_to_anchor?(element, leading, anchor)
473
+ case leading
474
+ when :child
475
+ same_node?(parent_element(element), anchor)
476
+ when :descendant
477
+ a = parent_element(element)
478
+ a = parent_element(a) until a.nil? || same_node?(a, anchor)
479
+ !a.nil?
480
+ when :next_sibling
481
+ same_node?(previous_element(element), anchor)
482
+ when :subsequent_sibling
483
+ s = previous_element(element)
484
+ s = previous_element(s) until s.nil? || same_node?(s, anchor)
485
+ !s.nil?
486
+ end
487
+ end
488
+
489
+ def sibling_leading?(combinator)
490
+ combinator == :next_sibling || combinator == :subsequent_sibling
491
+ end
492
+
493
+ def match_nth(element, anb, of_type:, from_end:, cache:, state:)
275
494
  return false unless anb.is_a?(AnB)
276
495
 
277
- index = nth_index(element, of_type:, from_end:)
496
+ index =
497
+ if anb.of
498
+ # `of S` — the element must itself match S, and is indexed among
499
+ # only the siblings that also match S.
500
+ return false unless matches?(element, anb.of, cache: cache, state: state)
501
+
502
+ nth_index_matching(element, anb.of, from_end:, cache:, state:)
503
+ else
504
+ nth_index(element, of_type:, from_end:)
505
+ end
278
506
 
279
507
  return false if index.nil?
280
508
 
@@ -307,6 +535,24 @@ module CSS
307
535
  idx && idx + 1
308
536
  end
309
537
 
538
+ # Index of `element` (1-based) counting only the siblings that match the
539
+ # `of S` selector list — used by `:nth-child(An+B of S)`.
540
+ def nth_index_matching(element, selector_list, from_end:, cache:, state:)
541
+ return nil if parent_element(element).nil?
542
+
543
+ direction = from_end ? :next_element : :previous_element
544
+ count = 0
545
+ sib = send(direction, element)
546
+
547
+ while sib
548
+ count += 1 if matches?(sib, selector_list, cache: cache, state: state)
549
+
550
+ sib = send(direction, sib)
551
+ end
552
+
553
+ count + 1
554
+ end
555
+
310
556
  # Form / link state -----------------------------------------------
311
557
 
312
558
  def link?(element)
@@ -400,6 +646,73 @@ module CSS
400
646
  v.nil? || v.empty?
401
647
  end
402
648
 
649
+ # `:default` — a selected option, a checked checkbox / radio, or a
650
+ # form's first submit button. Computed structurally (no host state).
651
+ def default?(element)
652
+ case tag(element)
653
+ when 'option'
654
+ !attr(element, 'selected').nil?
655
+ when 'input'
656
+ type = attr(element, 'type').to_s.downcase
657
+
658
+ if type == 'checkbox' || type == 'radio'
659
+ !attr(element, 'checked').nil?
660
+ else
661
+ submit_button?(element) && default_submit?(element)
662
+ end
663
+ when 'button'
664
+ submit_button?(element) && default_submit?(element)
665
+ else
666
+ false
667
+ end
668
+ end
669
+
670
+ def submit_button?(element)
671
+ case tag(element)
672
+ when 'button'
673
+ # `<button>`'s type is enumerated with Submit as both the missing-
674
+ # and invalid-value default, so it is a submit button unless it is
675
+ # explicitly `reset` or `button` (`type=""` / `type="x"` are submit).
676
+ type = attr(element, 'type').to_s.downcase
677
+ type != 'reset' && type != 'button'
678
+ when 'input'
679
+ type = attr(element, 'type').to_s.downcase
680
+ type == 'submit' || type == 'image'
681
+ else
682
+ false
683
+ end
684
+ end
685
+
686
+ def default_submit?(element)
687
+ form = nil
688
+ ancestor = parent_element(element)
689
+
690
+ while ancestor
691
+ if tag(ancestor) == 'form'
692
+ form = ancestor
693
+ break
694
+ end
695
+
696
+ ancestor = parent_element(ancestor)
697
+ end
698
+
699
+ !form.nil? && same_node?(first_submit(form), element)
700
+ end
701
+
702
+ def first_submit(node)
703
+ element_children(node).each do |child|
704
+ return child if submit_button?(child)
705
+
706
+ # A nested <form>'s controls belong to that form, not this one.
707
+ next if tag(child) == 'form'
708
+
709
+ found = first_submit(child)
710
+ return found if found
711
+ end
712
+
713
+ nil
714
+ end
715
+
403
716
  def match_lang(element, argument)
404
717
  target = ident_argument(argument)
405
718
 
@@ -450,10 +763,19 @@ module CSS
450
763
  token&.value
451
764
  end
452
765
 
453
- # CSS3 :empty semantics — element children always disqualify;
454
- # whitespace-only text content does not. Comments / PIs / doctypes
455
- # are ignored.
456
- def empty?(element)
766
+ def empty_allows_whitespace?(state)
767
+ return true if state.nil?
768
+
769
+ v = state[EMPTY_WS_KEY]
770
+ v.nil? ? true : v
771
+ end
772
+
773
+ # `:empty` — element children always disqualify; comments / PIs /
774
+ # doctypes are ignored in both modes. Text content disqualifies per
775
+ # `allow_ws`: when true (Selectors-4 default) only non-whitespace text
776
+ # disqualifies (so `<p> </p>` is :empty); when false (real browsers /
777
+ # Selectors-3) any non-empty text does.
778
+ def empty?(element, allow_ws)
457
779
  return false unless element.respond_to?(:children)
458
780
 
459
781
  element.children.each do |child|
@@ -462,8 +784,8 @@ module CSS
462
784
  end
463
785
 
464
786
  if child.respond_to?(:text?) && child.text?
465
- content = child.respond_to?(:content) ? child.content : child.text
466
- return false if content.to_s.match?(/\S/)
787
+ content = (child.respond_to?(:content) ? child.content : child.text).to_s
788
+ return false if allow_ws ? content.match?(/\S/) : !content.empty?
467
789
  end
468
790
  end
469
791
 
@@ -24,8 +24,20 @@ module CSS
24
24
  def to_s = Selectors::Serializer.serialize(self)
25
25
  end
26
26
 
27
- TypeSelector = Data.define(:name) { include Node }
28
- UniversalSelector = Data.define { include Node }
27
+ # `namespace` is the namespace constraint: `nil` (no prefix — any
28
+ # namespace), `'*'` (`*|name`, any), or `''` (`|name`, no namespace). A
29
+ # declared prefix (`svg|name`) is rejected at parse time — there is no
30
+ # `@namespace` mechanism.
31
+ TypeSelector = Data.define(:name, :namespace) do
32
+ include Node
33
+ def initialize(name:, namespace: nil) = super
34
+ end
35
+
36
+ UniversalSelector = Data.define(:namespace) do
37
+ include Node
38
+ def initialize(namespace: nil) = super
39
+ end
40
+
29
41
  NestingSelector = Data.define { include Node }
30
42
  IdSelector = Data.define(:name) { include Node }
31
43
  ClassSelector = Data.define(:name) { include Node }
@@ -39,9 +51,14 @@ module CSS
39
51
  # :suffix — `[a$=b]`
40
52
  # :substring — `[a*=b]`
41
53
  #
42
- # `case_flag` is `nil`, `:i`, or `:s`.
43
- AttributeSelector = Data.define(:name, :matcher, :value, :case_flag) do
54
+ # `case_flag` is `nil`, `:i`, or `:s`. `namespace` is the attribute
55
+ # namespace constraint (`nil` = no prefix, `'*'` = any, `''` = no
56
+ # namespace); a declared prefix is rejected. Attribute namespaces aren't
57
+ # tracked at match time (HTML attributes are all in no namespace), so
58
+ # matching is by local name.
59
+ AttributeSelector = Data.define(:name, :matcher, :value, :case_flag, :namespace) do
44
60
  include Node
61
+ def initialize(name:, matcher:, value:, case_flag:, namespace: nil) = super
45
62
  end
46
63
 
47
64
  # `argument` is `nil`, a `SelectorList` (`:not/:is/:where/:has`), an
@@ -52,8 +69,30 @@ module CSS
52
69
 
53
70
  # `An+B` integer pair. `step` is the `n` coefficient, `offset` is the
54
71
  # constant term. `even` => AnB(2, 0), `odd` => AnB(2, 1), `5` => AnB(0, 5),
55
- # `n` => AnB(1, 0).
56
- AnB = Data.define(:step, :offset) do
72
+ # `n` => AnB(1, 0). `of` is the optional `of S` filter (a `SelectorList`),
73
+ # `nil` except on `:nth-child` / `:nth-last-child`.
74
+ AnB = Data.define(:step, :offset, :of) do
75
+ include Node
76
+
77
+ def initialize(step:, offset:, of: nil)
78
+ super
79
+ end
80
+
81
+ def to_s = Selectors::Serializer.serialize(self)
82
+ end
83
+
84
+ # The argument of `:has()` — a comma-separated list of relative selectors.
85
+ RelativeSelectorList = Data.define(:selectors) do
86
+ include Node
87
+ def to_s = Selectors::Serializer.serialize(self)
88
+ end
89
+
90
+ # One relative selector: an (optionally explicit) leading combinator
91
+ # relative to the `:has()` anchor, then a complex selector. `combinator`
92
+ # is `:descendant` (the implicit default, `:has(.x)`), `:child`
93
+ # (`:has(> .x)`), `:next_sibling` (`:has(+ .x)`), or `:subsequent_sibling`
94
+ # (`:has(~ .x)`).
95
+ RelativeSelector = Data.define(:combinator, :complex) do
57
96
  include Node
58
97
  def to_s = Selectors::Serializer.serialize(self)
59
98
  end
@@ -4,21 +4,32 @@ module CSS
4
4
  # selectors, the four standard combinators (descendant, child, next-
5
5
  # sibling, subsequent-sibling), pseudo-classes / pseudo-elements
6
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.
7
+ # `:nth-*`, including `An+B of S`), attribute selectors with case-
8
+ # insensitive `i` / `s` flags, the `&` nesting selector, and namespace
9
+ # prefixes (`*|name`, `|name`; a declared prefix is rejected — there is no
10
+ # `@namespace` support).
9
11
  #
10
- # Out of scope (intermediate plan): namespace prefixes, the column
11
- # combinator `||`, and forgiving vs strict selector list distinctions.
12
+ # Out of scope: the column combinator `||`, and forgiving vs strict
13
+ # selector list distinctions.
12
14
  class Parser
13
15
  include CSS::TokenCursor
14
16
 
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
17
  SELECTOR_LIST_PSEUDOS = %w[is where not matches].freeze
20
18
  ANB_PSEUDOS = %w[nth-child nth-last-child nth-of-type nth-last-of-type].freeze
21
19
 
20
+ # Per the Selectors grammar (and every browser's querySelector), a known
21
+ # pseudo-element is a valid selector that simply matches no element, while
22
+ # an unknown one (`::example`) is a syntax error. Vendor-prefixed
23
+ # `::-webkit-…` are accepted leniently since they match nothing.
24
+ KNOWN_PSEUDO_ELEMENTS = %w[
25
+ before after first-line first-letter
26
+ marker placeholder selection backdrop file-selector-button
27
+ target-text grammar-error spelling-error highlight
28
+ cue cue-region part slotted details-content
29
+ view-transition view-transition-group view-transition-image-pair
30
+ view-transition-old view-transition-new
31
+ ].to_set.freeze
32
+
22
33
  ATTR_MATCHERS = {
23
34
  '~' => :includes,
24
35
  '|' => :dash,
@@ -36,6 +47,14 @@ module CSS
36
47
  new(tokens_from(input)).parse_selector_complete
37
48
  end
38
49
 
50
+ # Like `parse_selector_list`, but each complex selector may begin with
51
+ # a combinator (`>` / `+` / `~`), synthesising a leading `&`. Used for
52
+ # CSS Nesting nested rules, whose preludes follow the
53
+ # `<relative-selector-list>` grammar.
54
+ def parse_nesting_selector_list(input)
55
+ new(tokens_from(input)).parse_nesting_selector_list_complete
56
+ end
57
+
39
58
  private
40
59
 
41
60
  def tokens_from(input)
@@ -140,6 +159,58 @@ module CSS
140
159
  ComplexSelector.new(compounds:, combinators:)
141
160
  end
142
161
 
162
+ def parse_nesting_selector_list_complete
163
+ list = parse_nesting_selector_list
164
+
165
+ skip_whitespace
166
+
167
+ parse_error!("trailing tokens after selector list: #{peek.type}") unless peek.type == :eof
168
+
169
+ list
170
+ end
171
+
172
+ def parse_nesting_selector_list
173
+ skip_whitespace
174
+
175
+ parse_error!('empty selector list') if list_terminator?(peek)
176
+
177
+ selectors = [parse_nesting_complex_selector]
178
+
179
+ loop do
180
+ skip_whitespace
181
+ break unless peek.type == :comma
182
+
183
+ consume
184
+ skip_whitespace
185
+ selectors << parse_nesting_complex_selector
186
+ end
187
+
188
+ SelectorList.new(selectors:)
189
+ end
190
+
191
+ # A nested-rule complex selector may start with a combinator, which
192
+ # implies a leading `&` (`> .c` → `& > .c`), preserving the
193
+ # `compounds == combinators + 1` invariant.
194
+ def parse_nesting_complex_selector
195
+ skip_whitespace
196
+
197
+ t = peek
198
+
199
+ if t.type == :delim && (combo = combinator_for_delim(t.value))
200
+ consume
201
+ skip_whitespace
202
+
203
+ rest = parse_complex_selector
204
+
205
+ return ComplexSelector.new(
206
+ compounds: [CompoundSelector.new(components: [NestingSelector.new]), *rest.compounds],
207
+ combinators: [combo, *rest.combinators]
208
+ )
209
+ end
210
+
211
+ parse_complex_selector
212
+ end
213
+
143
214
  private
144
215
 
145
216
  def consume_whitespace_returning_bool
@@ -190,7 +261,7 @@ module CSS
190
261
  when :ident, :hash, :lbracket, :colon
191
262
  true
192
263
  when :delim
193
- %w[* . &].include?(t.value)
264
+ %w[* . & |].include?(t.value)
194
265
  else
195
266
  false
196
267
  end
@@ -215,16 +286,53 @@ module CSS
215
286
  CompoundSelector.new(components:)
216
287
  end
217
288
 
289
+ # Consumes an optional namespace prefix (`*|`, `|`, or `prefix|`) and
290
+ # returns the namespace: `'*'` (any), `''` (no namespace), or `nil` when
291
+ # there is no prefix (nothing consumed). A declared prefix (`svg|`) is
292
+ # invalid — there is no `@namespace` mechanism — so it raises.
293
+ def try_consume_namespace_prefix
294
+ t = peek
295
+
296
+ # `ident|` or `*|` (immediately, not `…|=` which is an attribute matcher).
297
+ if (t.type == :ident || (t.type == :delim && t.value == '*')) &&
298
+ peek(1).type == :delim && peek(1).value == '|' &&
299
+ peek(2).value != '='
300
+ parse_error!("undeclared namespace prefix '#{t.value}'") if t.type == :ident
301
+
302
+ consume # *
303
+ consume # |
304
+ return '*'
305
+ end
306
+
307
+ # Leading `|` — the no-namespace prefix (not `|=`).
308
+ if t.type == :delim && t.value == '|' && peek(1).value != '='
309
+ consume # |
310
+ return ''
311
+ end
312
+
313
+ nil
314
+ end
315
+
218
316
  def try_consume_type_or_universal
317
+ ns = try_consume_namespace_prefix
318
+
219
319
  case peek.type
220
320
  when :ident
221
- TypeSelector.new(name: consume.value)
321
+ return TypeSelector.new(name: consume.value, namespace: ns)
222
322
  when :delim
223
- case peek.value
224
- when '*' then consume; UniversalSelector.new
225
- when '&' then consume; NestingSelector.new
323
+ if peek.value == '*'
324
+ consume
325
+ return UniversalSelector.new(namespace: ns)
226
326
  end
227
327
  end
328
+
329
+ # A consumed prefix must be followed by a name or `*`.
330
+ parse_error!("expected element name or '*' after namespace prefix") unless ns.nil?
331
+
332
+ if peek.type == :delim && peek.value == '&'
333
+ consume
334
+ NestingSelector.new
335
+ end
228
336
  end
229
337
 
230
338
  def try_consume_subclass_or_pseudo
@@ -263,6 +371,8 @@ module CSS
263
371
  consume # [
264
372
  skip_whitespace
265
373
 
374
+ namespace = try_consume_namespace_prefix
375
+
266
376
  parse_error!('expected attribute name') unless peek.type == :ident
267
377
 
268
378
  name = consume.value
@@ -278,7 +388,7 @@ module CSS
278
388
 
279
389
  consume
280
390
 
281
- AttributeSelector.new(name:, matcher:, value:, case_flag:)
391
+ AttributeSelector.new(name:, matcher:, value:, case_flag:, namespace:)
282
392
  end
283
393
 
284
394
  def parse_attr_matcher_and_value
@@ -332,21 +442,31 @@ module CSS
332
442
  end
333
443
 
334
444
  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)
445
+ head = peek.type
446
+
447
+ unless head == :ident || head == :function
448
+ parse_error!("expected pseudo-#{element ? 'element' : 'class'} name, got #{head}")
449
+ end
342
450
 
343
- parse_error!("expected ')' to close :#{name}") unless peek.type == :rparen
451
+ name = consume.value
344
452
 
345
- consume
346
- build_pseudo(element:, name:, argument: arg)
347
- else
348
- parse_error!("expected pseudo-#{element ? 'element' : 'class'} name, got #{peek.type}")
453
+ if element && !known_pseudo_element?(name)
454
+ parse_error!("unknown pseudo-element ::#{name}")
349
455
  end
456
+
457
+ return build_pseudo(element:, name:, argument: nil) if head == :ident
458
+
459
+ arg = parse_pseudo_argument(name)
460
+
461
+ parse_error!("expected ')' to close :#{name}") unless peek.type == :rparen
462
+
463
+ consume
464
+ build_pseudo(element:, name:, argument: arg)
465
+ end
466
+
467
+ def known_pseudo_element?(name)
468
+ n = name.downcase
469
+ n.start_with?('-') || KNOWN_PSEUDO_ELEMENTS.include?(n)
350
470
  end
351
471
 
352
472
  def build_pseudo(element:, name:, argument:)
@@ -358,13 +478,87 @@ module CSS
358
478
 
359
479
  if SELECTOR_LIST_PSEUDOS.include?(n)
360
480
  parse_selector_list
481
+ elsif n == 'has'
482
+ parse_relative_selector_list
361
483
  elsif ANB_PSEUDOS.include?(n)
362
- AnBParser.parse(collect_argument_tokens)
484
+ parse_nth_argument(allow_of: n == 'nth-child' || n == 'nth-last-child')
363
485
  else
364
486
  collect_argument_tokens
365
487
  end
366
488
  end
367
489
 
490
+ # `:nth-*` argument: An+B, optionally followed by `of <selector-list>`
491
+ # (Selectors-4, only on `:nth-child` / `:nth-last-child`). Collects the
492
+ # An+B tokens up to a top-level `of` ident, then parses S inline.
493
+ def parse_nth_argument(allow_of:)
494
+ anb_tokens = []
495
+ depth = 0
496
+
497
+ loop do
498
+ t = peek
499
+
500
+ parse_error!('unexpected EOF in :nth argument') if t.type == :eof
501
+
502
+ if depth.zero?
503
+ break if t.type == :rparen
504
+ break if allow_of && t.type == :ident && t.value.downcase == 'of'
505
+ end
506
+
507
+ case t.type
508
+ when :function, :lparen then depth += 1
509
+ when :rparen then depth -= 1
510
+ end
511
+
512
+ anb_tokens << consume
513
+ end
514
+
515
+ anb = AnBParser.parse(anb_tokens)
516
+
517
+ return anb unless allow_of && peek.type == :ident && peek.value.downcase == 'of'
518
+
519
+ consume # `of`
520
+ skip_whitespace
521
+ AnB.new(step: anb.step, offset: anb.offset, of: parse_selector_list)
522
+ end
523
+
524
+ # `:has()` argument: a comma-separated list of relative selectors, each
525
+ # an optional leading combinator (`>`, `+`, `~`; default descendant)
526
+ # followed by a complex selector. Terminated by EOF or the closing `)`.
527
+ def parse_relative_selector_list
528
+ skip_whitespace
529
+
530
+ parse_error!('empty :has() argument') if list_terminator?(peek)
531
+
532
+ selectors = [parse_relative_selector]
533
+
534
+ loop do
535
+ skip_whitespace
536
+ break unless peek.type == :comma
537
+
538
+ consume
539
+ skip_whitespace
540
+ selectors << parse_relative_selector
541
+ end
542
+
543
+ RelativeSelectorList.new(selectors:)
544
+ end
545
+
546
+ def parse_relative_selector
547
+ skip_whitespace
548
+
549
+ combinator = :descendant
550
+
551
+ t = peek
552
+
553
+ if t.type == :delim && (combo = combinator_for_delim(t.value))
554
+ combinator = combo
555
+ consume
556
+ skip_whitespace
557
+ end
558
+
559
+ RelativeSelector.new(combinator:, complex: parse_complex_selector)
560
+ end
561
+
368
562
  # Collects all tokens up to the closing `)` of the current functional
369
563
  # context, balancing nested parens / functions.
370
564
  def collect_argument_tokens
@@ -10,6 +10,15 @@ module CSS
10
10
  subsequent_sibling: ' ~ '
11
11
  }.freeze
12
12
 
13
+ # Leading combinator of a `:has()` relative selector (descendant is
14
+ # implicit, so it has no prefix).
15
+ LEADING_COMBINATOR_GLUE = {
16
+ descendant: '',
17
+ child: '> ',
18
+ next_sibling: '+ ',
19
+ subsequent_sibling: '~ '
20
+ }.freeze
21
+
13
22
  ATTR_OPS = {
14
23
  exact: '=',
15
24
  includes: '~=',
@@ -24,15 +33,17 @@ module CSS
24
33
  when SelectorList then node.selectors.map { serialize(_1) }.join(', ')
25
34
  when ComplexSelector then serialize_complex(node)
26
35
  when CompoundSelector then node.components.map { serialize(_1) }.join
27
- when TypeSelector then Escape.ident(node.name)
28
- when UniversalSelector then '*'
36
+ when TypeSelector then ns_prefix(node.namespace) + Escape.ident(node.name)
37
+ when UniversalSelector then "#{ns_prefix(node.namespace)}*"
29
38
  when NestingSelector then '&'
30
39
  when IdSelector then "##{Escape.ident(node.name)}"
31
40
  when ClassSelector then ".#{Escape.ident(node.name)}"
32
41
  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)
42
+ when PseudoClass then serialize_pseudo(node, '')
43
+ when PseudoElement then serialize_pseudo(node, ':')
44
+ when AnB then serialize_anb(node)
45
+ when RelativeSelectorList then node.selectors.map { serialize_relative(_1) }.join(', ')
46
+ when RelativeSelector then serialize_relative(node)
36
47
  else
37
48
  raise ArgumentError, "cannot serialize selector node #{node.class}"
38
49
  end
@@ -40,6 +51,10 @@ module CSS
40
51
 
41
52
  private
42
53
 
54
+ def serialize_relative(rel)
55
+ LEADING_COMBINATOR_GLUE.fetch(rel.combinator) + serialize(rel.complex)
56
+ end
57
+
43
58
  def serialize_complex(cs)
44
59
  out = +serialize(cs.compounds[0])
45
60
 
@@ -50,8 +65,16 @@ module CSS
50
65
  out
51
66
  end
52
67
 
68
+ def ns_prefix(namespace)
69
+ case namespace
70
+ when '*' then '*|'
71
+ when '' then '|'
72
+ else ''
73
+ end
74
+ end
75
+
53
76
  def serialize_attribute(attr)
54
- out = +"[#{Escape.ident(attr.name)}"
77
+ out = +"[#{ns_prefix(attr.namespace)}#{Escape.ident(attr.name)}"
55
78
 
56
79
  if attr.matcher
57
80
  out << ATTR_OPS.fetch(attr.matcher) << Escape.string(attr.value.to_s)
@@ -71,15 +94,22 @@ module CSS
71
94
 
72
95
  def serialize_argument(arg)
73
96
  case arg
74
- when SelectorList then serialize(arg)
75
- when AnB then serialize_anb(arg)
76
- when Array then CSS::Serializer.serialize(arg)
97
+ when SelectorList then serialize(arg)
98
+ when RelativeSelectorList then serialize(arg)
99
+ when AnB then serialize_anb(arg)
100
+ when Array then CSS::Serializer.serialize(arg)
77
101
  else
78
102
  raise ArgumentError, "unknown pseudo argument #{arg.class}"
79
103
  end
80
104
  end
81
105
 
82
106
  def serialize_anb(anb)
107
+ out = serialize_anb_value(anb)
108
+ out += " of #{serialize(anb.of)}" if anb.of
109
+ out
110
+ end
111
+
112
+ def serialize_anb_value(anb)
83
113
  return 'even' if anb.step == 2 && anb.offset.zero?
84
114
  return 'odd' if anb.step == 2 && anb.offset == 1
85
115
 
@@ -66,12 +66,26 @@ module CSS
66
66
  case node.name.downcase
67
67
  when 'where'
68
68
  Specificity::ZERO
69
- when 'is', 'not', 'has', 'matches'
69
+ when 'has'
70
+ # `:has()` contributes the most specific complex selector in its
71
+ # relative-selector-list argument (like `:is`).
72
+ if node.argument.is_a?(RelativeSelectorList)
73
+ node.argument.selectors.map { calculate(_1.complex) }.max || Specificity.new(a: 0, b: 1, c: 0)
74
+ else
75
+ Specificity.new(a: 0, b: 1, c: 0)
76
+ end
77
+ when 'is', 'not', 'matches'
70
78
  if node.argument.is_a?(SelectorList)
71
79
  calculate(node.argument)
72
80
  else
73
81
  Specificity.new(a: 0, b: 1, c: 0)
74
82
  end
83
+ when 'nth-child', 'nth-last-child'
84
+ # `(0,1,0)` plus, for the `of S` form, the most specific complex
85
+ # selector in S.
86
+ base = Specificity.new(a: 0, b: 1, c: 0)
87
+ of = node.argument.is_a?(AnB) ? node.argument.of : nil
88
+ of.is_a?(SelectorList) ? base + calculate(of) : base
75
89
  else
76
90
  Specificity.new(a: 0, b: 1, c: 0)
77
91
  end
data/lib/css/tokenizer.rb CHANGED
@@ -183,9 +183,11 @@ module CSS
183
183
  o <= 0x08 || o == 0x0B || (0x0E..0x1F).cover?(o) || o == 0x7F
184
184
  end
185
185
 
186
- # §4.3.8.
186
+ # §4.3.8: `\` is a valid escape unless followed by a newline. EOF (c2 is
187
+ # nil) is NOT a newline, so `\` at end-of-input is a valid escape — it
188
+ # consumes to U+FFFD (§4.3.7), e.g. `#eof\` is the id `#eof␦`.
187
189
  def valid_escape?(c1, c2)
188
- c1 == '\\' && c2 != "\n" && !c2.nil?
190
+ c1 == '\\' && c2 != "\n"
189
191
  end
190
192
 
191
193
  # §4.3.9.
@@ -322,7 +324,9 @@ module CSS
322
324
  @pos += 1
323
325
  end
324
326
 
325
- return @chars[start, @pos - start].join unless c == '\\' && (n = @chars[@pos + 1]) && n != "\n"
327
+ # Inline valid_escape?: `\` is a valid escape unless followed by a
328
+ # newline; EOF (@chars[@pos + 1] is nil) is a valid escape → U+FFFD.
329
+ return @chars[start, @pos - start].join unless c == '\\' && @chars[@pos + 1] != "\n"
326
330
 
327
331
  buf = @chars[start, @pos - start].join.dup
328
332
  @pos += 1
@@ -334,7 +338,7 @@ module CSS
334
338
  if o >= 128 || IDENT_CP_TABLE[o]
335
339
  buf << c
336
340
  @pos += 1
337
- elsif c == '\\' && (n = @chars[@pos + 1]) && n != "\n"
341
+ elsif c == '\\' && @chars[@pos + 1] != "\n"
338
342
  @pos += 1
339
343
  buf << consume_escaped_code_point
340
344
  else
data/lib/css/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module CSS
2
- VERSION = '0.3.1'
2
+ VERSION = '0.4.0'
3
3
  end
data/lib/css.rb CHANGED
@@ -50,7 +50,21 @@ module CSS
50
50
 
51
51
  def specificity(selector) = Selectors::SpecificityCalculator.calculate(selector)
52
52
 
53
- def matches?(element, selector, state: nil) = Selectors::Matcher.matches?(element, selector, state: state)
53
+ def matches?(element, selector, state: nil, scope: nil, empty_allows_whitespace: true)
54
+ Selectors::Matcher.matches?(element, selector, state:, scope:, empty_allows_whitespace:)
55
+ end
56
+
57
+ def select_all(roots, selector, state: nil, scope: nil, empty_allows_whitespace: true)
58
+ Selectors::Matcher.select_all(roots, selector, state:, scope:, empty_allows_whitespace:)
59
+ end
60
+
61
+ def select_first(roots, selector, state: nil, scope: nil, empty_allows_whitespace: true)
62
+ Selectors::Matcher.select_first(roots, selector, state:, scope:, empty_allows_whitespace:)
63
+ end
64
+
65
+ def closest(element, selector, state: nil, scope: nil, empty_allows_whitespace: true)
66
+ Selectors::Matcher.closest(element, selector, state:, scope:, empty_allows_whitespace:)
67
+ end
54
68
 
55
69
  def parse_media_query_list(input) = MediaQueries::Parser.parse(input)
56
70
 
@@ -17,11 +17,14 @@ module CSS
17
17
  type case_flag = :i | :s
18
18
 
19
19
  # `argument` is `nil` for plain `:hover` etc., a `SelectorList` for
20
- # `:not / :is / :where / :matches`, an `AnB` for `:nth-*`, or an
21
- # opaque component-value array for unrecognized functional pseudos
22
- # (including `:has`, which is intentionally not parsed as a selector
23
- # list yet).
24
- type pseudo_argument = nil | SelectorList | AnB | Array[component_value]
20
+ # `:not / :is / :where / :matches`, a `RelativeSelectorList` for `:has`,
21
+ # an `AnB` for `:nth-*`, or an opaque component-value array for
22
+ # unrecognized functional pseudos.
23
+ type pseudo_argument = nil | SelectorList | RelativeSelectorList | AnB | Array[component_value]
24
+
25
+ # Namespace constraint: `nil` (no prefix — any namespace), `'*'` (any),
26
+ # or `''` (no namespace).
27
+ type namespace = String?
25
28
 
26
29
  class SelectorList < Data
27
30
  include Node
@@ -60,14 +63,17 @@ module CSS
60
63
  include Node
61
64
 
62
65
  attr_reader name: String
66
+ attr_reader namespace: namespace
63
67
 
64
- def self.new: (name: String) -> TypeSelector
68
+ def self.new: (name: String, ?namespace: namespace) -> TypeSelector
65
69
  end
66
70
 
67
71
  class UniversalSelector < Data
68
72
  include Node
69
73
 
70
- def self.new: () -> UniversalSelector
74
+ attr_reader namespace: namespace
75
+
76
+ def self.new: (?namespace: namespace) -> UniversalSelector
71
77
  end
72
78
 
73
79
  class NestingSelector < Data
@@ -99,8 +105,9 @@ module CSS
99
105
  attr_reader matcher: attribute_matcher?
100
106
  attr_reader value: String?
101
107
  attr_reader case_flag: case_flag?
108
+ attr_reader namespace: namespace
102
109
 
103
- def self.new: (name: String, matcher: attribute_matcher?, value: String?, case_flag: case_flag?) -> AttributeSelector
110
+ def self.new: (name: String, matcher: attribute_matcher?, value: String?, case_flag: case_flag?, ?namespace: namespace) -> AttributeSelector
104
111
  end
105
112
 
106
113
  class PseudoClass < Data
@@ -126,8 +133,34 @@ module CSS
126
133
 
127
134
  attr_reader step: Integer
128
135
  attr_reader offset: Integer
136
+ attr_reader of: SelectorList?
137
+
138
+ def self.new: (step: Integer, offset: Integer, ?of: SelectorList?) -> AnB
139
+
140
+ def to_s: () -> String
141
+ end
142
+
143
+ # The argument of `:has()` — a list of relative selectors.
144
+ class RelativeSelectorList < Data
145
+ include Node
146
+
147
+ attr_reader selectors: Array[RelativeSelector]
129
148
 
130
- def self.new: (step: Integer, offset: Integer) -> AnB
149
+ def self.new: (selectors: Array[RelativeSelector]) -> RelativeSelectorList
150
+
151
+ def to_s: () -> String
152
+ end
153
+
154
+ # One relative selector: a leading combinator (`:descendant` default,
155
+ # `:child`, `:next_sibling`, `:subsequent_sibling`) plus a complex
156
+ # selector.
157
+ class RelativeSelector < Data
158
+ include Node
159
+
160
+ attr_reader combinator: combinator
161
+ attr_reader complex: ComplexSelector
162
+
163
+ def self.new: (combinator: combinator, complex: ComplexSelector) -> RelativeSelector
131
164
 
132
165
  def to_s: () -> String
133
166
  end
@@ -154,7 +187,11 @@ module CSS
154
187
  STATEFUL_PSEUDOS: Set[String]
155
188
  PROPAGATING_STATEFUL_PSEUDOS: Set[String]
156
189
 
157
- def self.matches?: (untyped element, untyped selector, ?cache: Hash[Integer, untyped], ?state: matcher_state?) -> bool
190
+ def self.matches?: (untyped element, untyped selector, ?cache: Hash[Integer, untyped], ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> bool
191
+
192
+ def self.select_all: (untyped roots, untyped selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> Array[untyped]
193
+ def self.select_first: (untyped roots, untyped selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> untyped
194
+ def self.closest: (untyped element, untyped selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> untyped
158
195
 
159
196
  def self.tag_of: (untyped element, ?Hash[Integer, untyped]?) -> String
160
197
  def self.id_of: (untyped element, ?Hash[Integer, untyped]?) -> String?
data/sig/css.rbs CHANGED
@@ -73,7 +73,16 @@ module CSS
73
73
  # specific elements in that state, or falsy (no match).
74
74
  type matcher_state = Hash[Symbol | String, bool | Set[untyped] | Array[untyped]]
75
75
 
76
- def self.matches?: (untyped element, String | selector selector, ?cache: Hash[Integer, untyped], ?state: matcher_state?) -> bool
76
+ # The set `:scope` matches: a single element, or an Array / Set of them.
77
+ type scope = untyped | Array[untyped] | Set[untyped]
78
+
79
+ def self.matches?: (untyped element, String | selector selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> bool
80
+
81
+ # querySelector-style tree queries over the descendants of `roots` (an
82
+ # element or array of elements). `closest` walks inclusive ancestors.
83
+ def self.select_all: (untyped roots, String | selector selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> Array[untyped]
84
+ def self.select_first: (untyped roots, String | selector selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> untyped
85
+ def self.closest: (untyped element, String | selector selector, ?state: matcher_state?, ?scope: scope?, ?empty_allows_whitespace: bool) -> untyped
77
86
 
78
87
  # Media queries (Media Queries 4)
79
88
  # ---------------------------------------------------------------
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: p_css
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima