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 +4 -4
- data/README.md +37 -10
- data/lib/css/nesting.rb +10 -1
- data/lib/css/selectors/matcher.rb +344 -22
- data/lib/css/selectors/nodes.rb +45 -6
- data/lib/css/selectors/parser.rb +221 -27
- data/lib/css/selectors/serializer.rb +39 -9
- data/lib/css/selectors/specificity.rb +15 -1
- data/lib/css/tokenizer.rb +8 -4
- data/lib/css/version.rb +1 -1
- data/lib/css.rb +15 -1
- data/sig/css/selectors.rbs +47 -10
- data/sig/css.rbs +10 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: caf08474039a751417ef1a6469b5e115206a0d7347234f13b703cf23c02e687f
|
|
4
|
+
data.tar.gz: 6f2f93498e29789e8d2c95f97b307835c5e4706d87af5b88056213272c650f96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
21
|
-
#
|
|
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[
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
339
|
+
when 'has' then match_has(element, pc.argument, cache, state)
|
|
209
340
|
when 'root' then parent_element(element).nil?
|
|
210
|
-
when 'scope' then
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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.
|
|
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
|
|
data/lib/css/selectors/nodes.rb
CHANGED
|
@@ -24,8 +24,20 @@ module CSS
|
|
|
24
24
|
def to_s = Selectors::Serializer.serialize(self)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/css/selectors/parser.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
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
|
|
11
|
-
#
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
451
|
+
name = consume.value
|
|
344
452
|
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
when PseudoElement
|
|
35
|
-
when AnB
|
|
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
|
|
75
|
-
when
|
|
76
|
-
when
|
|
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 '
|
|
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"
|
|
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
|
-
|
|
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 == '\\' &&
|
|
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
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
|
|
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
|
|
data/sig/css/selectors.rbs
CHANGED
|
@@ -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`,
|
|
21
|
-
# opaque component-value array for
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
# ---------------------------------------------------------------
|