dommy 0.7.0 → 0.8.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/lib/dommy/animation.rb +9 -1
- data/lib/dommy/attr.rb +192 -39
- data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
- data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
- data/lib/dommy/backend.rb +46 -0
- data/lib/dommy/blob.rb +28 -9
- data/lib/dommy/bridge/constructor_registry.rb +28 -0
- data/lib/dommy/bridge/methods.rb +57 -0
- data/lib/dommy/bridge.rb +97 -0
- data/lib/dommy/callable_invoker.rb +36 -0
- data/lib/dommy/cookie_store.rb +3 -1
- data/lib/dommy/crypto.rb +7 -1
- data/lib/dommy/css.rb +46 -0
- data/lib/dommy/custom_elements.rb +27 -3
- data/lib/dommy/data_transfer.rb +4 -0
- data/lib/dommy/document.rb +615 -48
- data/lib/dommy/dom_parser.rb +28 -15
- data/lib/dommy/element.rb +999 -471
- data/lib/dommy/event.rb +260 -96
- data/lib/dommy/event_source.rb +6 -2
- data/lib/dommy/fetch.rb +505 -43
- data/lib/dommy/file_reader.rb +11 -3
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +43 -8
- data/lib/dommy/html_collection.rb +55 -2
- data/lib/dommy/html_elements.rb +102 -1519
- data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +26 -0
- data/lib/dommy/internal/idna.rb +16 -7
- data/lib/dommy/internal/ipv4_parser.rb +22 -7
- data/lib/dommy/internal/mutation_coordinator.rb +11 -2
- data/lib/dommy/internal/namespaces.rb +70 -0
- data/lib/dommy/internal/node_equality.rb +86 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
- data/lib/dommy/internal/observable_callback.rb +1 -5
- data/lib/dommy/internal/parent_node.rb +126 -0
- data/lib/dommy/internal/reflected_attributes.rb +103 -13
- data/lib/dommy/internal/selector_parser.rb +664 -0
- data/lib/dommy/internal/url_parser.rb +677 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +7 -1
- data/lib/dommy/message_channel.rb +32 -2
- data/lib/dommy/mutation_observer.rb +55 -12
- data/lib/dommy/navigator.rb +26 -12
- data/lib/dommy/node.rb +158 -28
- data/lib/dommy/notification.rb +3 -1
- data/lib/dommy/performance.rb +4 -0
- data/lib/dommy/performance_observer.rb +2 -0
- data/lib/dommy/promise.rb +14 -14
- data/lib/dommy/range.rb +74 -5
- data/lib/dommy/resize_observer.rb +2 -0
- data/lib/dommy/scheduler.rb +34 -13
- data/lib/dommy/shadow_root.rb +23 -54
- data/lib/dommy/storage.rb +2 -0
- data/lib/dommy/streams.rb +18 -27
- data/lib/dommy/svg_elements.rb +204 -3606
- data/lib/dommy/text_codec.rb +174 -21
- data/lib/dommy/tree_walker.rb +255 -66
- data/lib/dommy/url.rb +287 -449
- data/lib/dommy/url_pattern.rb +2 -0
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +37 -7
- data/lib/dommy/window.rb +202 -213
- data/lib/dommy/worker.rb +7 -7
- data/lib/dommy/xml_http_request.rb +15 -5
- data/lib/dommy.rb +7 -0
- metadata +12 -3
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Internal
|
|
7
|
+
# A CSS Selectors (Level 4) *validator*: parses a selector string against the
|
|
8
|
+
# grammar and raises on anything syntactically invalid, so
|
|
9
|
+
# querySelector/querySelectorAll/matches/closest throw a SyntaxError for
|
|
10
|
+
# exactly the inputs the spec requires — cases Nokogiri's CSS parser silently
|
|
11
|
+
# accepts (`[*=test]`, `div % p`, `..x`) or rejects with the wrong error.
|
|
12
|
+
#
|
|
13
|
+
# This only VALIDATES; matching is still delegated to the backend. It is a
|
|
14
|
+
# hand-written tokenizer + recursive-descent parser covering the productions
|
|
15
|
+
# the Selectors spec (and the WPT corpus) exercise: selector lists,
|
|
16
|
+
# combinators, type/universal selectors with namespace prefixes, id/class,
|
|
17
|
+
# attribute selectors (with matchers and case flags), and pseudo-classes /
|
|
18
|
+
# pseudo-elements (functional and simple). Because querySelector has no
|
|
19
|
+
# namespace declarations, any *named* namespace prefix is "undeclared" → a
|
|
20
|
+
# SyntaxError (only `*|`, `|`, and the default empty prefix are allowed).
|
|
21
|
+
module SelectorParser
|
|
22
|
+
class InvalidSelector < StandardError; end
|
|
23
|
+
|
|
24
|
+
# Pseudo-elements (used with `::`, plus the four legacy `:` forms). A `::x`
|
|
25
|
+
# outside this set is an unknown pseudo-element → SyntaxError.
|
|
26
|
+
KNOWN_PSEUDO_ELEMENTS = %w[
|
|
27
|
+
before after first-line first-letter selection placeholder marker backdrop
|
|
28
|
+
slotted cue file-selector-button first-letter grammar-error spelling-error
|
|
29
|
+
target-text highlight part view-transition view-transition-group
|
|
30
|
+
view-transition-image-pair view-transition-old view-transition-new
|
|
31
|
+
].to_set.freeze
|
|
32
|
+
|
|
33
|
+
# Functional pseudo-classes (followed by `(...)`). `slotted`/`cue`/`part`
|
|
34
|
+
# are functional pseudo-elements handled in the `::` path.
|
|
35
|
+
SELECTOR_LIST_FUNCTIONS = %w[not is where has matches].to_set.freeze
|
|
36
|
+
NTH_FUNCTIONS = %w[nth-child nth-last-child nth-of-type nth-last-of-type nth-col nth-last-col].to_set.freeze
|
|
37
|
+
IDENT_FUNCTIONS = %w[lang dir].to_set.freeze
|
|
38
|
+
NESTED_SELECTOR_FUNCTIONS = %w[host host-context current].to_set.freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Validate `selector`; raise DOMException::SyntaxError if it is not a valid
|
|
43
|
+
# selector list, else return the original string.
|
|
44
|
+
def validate!(selector)
|
|
45
|
+
new_parser(selector.to_s).parse_selector_list!
|
|
46
|
+
selector
|
|
47
|
+
rescue InvalidSelector => e
|
|
48
|
+
raise ::Dommy::DOMException::SyntaxError, "'#{selector}' is not a valid selector: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# True when `selector` parses cleanly (no raise).
|
|
52
|
+
def valid?(selector)
|
|
53
|
+
validate!(selector)
|
|
54
|
+
true
|
|
55
|
+
rescue ::Dommy::DOMException::SyntaxError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Return `selector` with the comma-clauses whose subject is a pseudo-element
|
|
60
|
+
# removed (`::before`, `:first-line` — they match no element, so dropping
|
|
61
|
+
# them is what querySelector should do; the backend would otherwise error or
|
|
62
|
+
# mis-parse `::`). If EVERY clause is a pseudo-element, returns a selector
|
|
63
|
+
# that matches nothing. Assumes `selector` is already known valid. Plain
|
|
64
|
+
# selectors (no `:`) are returned untouched without re-parsing.
|
|
65
|
+
def matchable_selector(selector)
|
|
66
|
+
s = selector.to_s
|
|
67
|
+
return s unless s.include?(":")
|
|
68
|
+
|
|
69
|
+
parser = Parser.new(s)
|
|
70
|
+
parser.parse_selector_list!
|
|
71
|
+
clauses = parser.clauses
|
|
72
|
+
return s unless clauses.any? { |c| c[:pseudo_subject] }
|
|
73
|
+
|
|
74
|
+
kept = clauses.reject { |c| c[:pseudo_subject] }
|
|
75
|
+
kept.empty? ? ":not(*)" : kept.map { |c| c[:text] }.join(", ")
|
|
76
|
+
rescue InvalidSelector
|
|
77
|
+
s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def new_parser(string)
|
|
81
|
+
Parser.new(string)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Recursive-descent parser over a character buffer. Methods raise
|
|
85
|
+
# InvalidSelector on the first grammar violation.
|
|
86
|
+
class Parser
|
|
87
|
+
WS = " \t\r\n\f"
|
|
88
|
+
|
|
89
|
+
# Per top-level clause: { text:, pseudo_subject: } where pseudo_subject is
|
|
90
|
+
# true when the clause's subject (rightmost compound) is a pseudo-element
|
|
91
|
+
# (`::before`, `:first-line`) — such a clause matches no element.
|
|
92
|
+
attr_reader :clauses
|
|
93
|
+
|
|
94
|
+
def initialize(string)
|
|
95
|
+
@s = string
|
|
96
|
+
@i = 0
|
|
97
|
+
@n = string.length
|
|
98
|
+
@clauses = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# selector-list := <complex-selector> (',' <complex-selector>)* with
|
|
102
|
+
# optional surrounding whitespace; an empty list or an empty element
|
|
103
|
+
# (leading/trailing/double comma) is invalid.
|
|
104
|
+
def parse_selector_list!
|
|
105
|
+
skip_ws
|
|
106
|
+
fail!("empty selector") if eof?
|
|
107
|
+
record_clause { parse_complex_selector! }
|
|
108
|
+
while peek == ","
|
|
109
|
+
advance
|
|
110
|
+
skip_ws
|
|
111
|
+
fail!("empty selector in list") if eof? || peek == ","
|
|
112
|
+
record_clause { parse_complex_selector! }
|
|
113
|
+
end
|
|
114
|
+
skip_ws
|
|
115
|
+
fail!("unexpected #{peek.inspect}") unless eof?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Capture a clause's source text + whether its subject is a pseudo-element.
|
|
119
|
+
def record_clause
|
|
120
|
+
start = @i
|
|
121
|
+
pseudo_subject = yield
|
|
122
|
+
@clauses << {text: @s[start...@i].strip, pseudo_subject: pseudo_subject}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# complex := <compound> ( <combinator> <compound> )*
|
|
126
|
+
# combinator is one of > + ~ >> || or descendant (whitespace). Returns
|
|
127
|
+
# whether the SUBJECT (last) compound is a pseudo-element.
|
|
128
|
+
def parse_complex_selector!
|
|
129
|
+
pseudo_subject = parse_compound_selector!
|
|
130
|
+
loop do
|
|
131
|
+
had_ws = skip_ws
|
|
132
|
+
# `)` ends a complex selector nested in a functional pseudo
|
|
133
|
+
# (`:not(div)`); `,` / EOF end one at the top level.
|
|
134
|
+
break if eof? || peek == "," || peek == ")"
|
|
135
|
+
|
|
136
|
+
if combinator_char?(peek)
|
|
137
|
+
consume_combinator!
|
|
138
|
+
skip_ws
|
|
139
|
+
fail!("dangling combinator") if eof? || peek == "," || combinator_char?(peek)
|
|
140
|
+
pseudo_subject = parse_compound_selector!
|
|
141
|
+
elsif had_ws
|
|
142
|
+
# Descendant combinator (whitespace) — next must be a compound.
|
|
143
|
+
pseudo_subject = parse_compound_selector!
|
|
144
|
+
else
|
|
145
|
+
fail!("unexpected #{peek.inspect}")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
pseudo_subject
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# One explicit combinator token: > , + , ~ , >> , || .
|
|
152
|
+
def consume_combinator!
|
|
153
|
+
c = peek
|
|
154
|
+
case c
|
|
155
|
+
when ">"
|
|
156
|
+
advance
|
|
157
|
+
advance if peek == ">" # legacy descendant `>>`
|
|
158
|
+
when "+", "~"
|
|
159
|
+
advance
|
|
160
|
+
fail!("invalid combinator") if peek == c # `++`, `~~`
|
|
161
|
+
when "|"
|
|
162
|
+
fail!("invalid combinator") unless peek(1) == "|"
|
|
163
|
+
advance
|
|
164
|
+
advance
|
|
165
|
+
else
|
|
166
|
+
fail!("invalid combinator #{c.inspect}")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def combinator_char?(c)
|
|
171
|
+
c == ">" || c == "+" || c == "~" || (c == "|" && peek(1) == "|")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# compound := [ <type> | <universal> ]? <subclass>* with at least one
|
|
175
|
+
# simple selector. A type/universal, if present, comes first. Returns
|
|
176
|
+
# whether the compound includes a pseudo-element (always the last token).
|
|
177
|
+
def parse_compound_selector!
|
|
178
|
+
saw_any = false
|
|
179
|
+
pseudo_element = false
|
|
180
|
+
# Optional leading type/universal (may carry a namespace prefix).
|
|
181
|
+
if type_start?
|
|
182
|
+
parse_type_or_universal!
|
|
183
|
+
saw_any = true
|
|
184
|
+
end
|
|
185
|
+
loop do
|
|
186
|
+
case peek
|
|
187
|
+
when "#"
|
|
188
|
+
parse_id!
|
|
189
|
+
when "."
|
|
190
|
+
parse_class!
|
|
191
|
+
when "["
|
|
192
|
+
parse_attribute!
|
|
193
|
+
when ":"
|
|
194
|
+
pseudo_element = parse_pseudo!
|
|
195
|
+
else
|
|
196
|
+
break
|
|
197
|
+
end
|
|
198
|
+
saw_any = true
|
|
199
|
+
end
|
|
200
|
+
fail!("empty compound selector") unless saw_any
|
|
201
|
+
|
|
202
|
+
pseudo_element
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A compound may start with a type/universal selector when the next token
|
|
206
|
+
# is an ident, `*`, or a namespace prefix (`*|`, `|`, `ident|`).
|
|
207
|
+
def type_start?
|
|
208
|
+
c = peek
|
|
209
|
+
return true if c == "*"
|
|
210
|
+
return true if c == "|"
|
|
211
|
+
return true if ident_start?
|
|
212
|
+
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# type := [<ns-prefix>]? (<ident> | '*')
|
|
217
|
+
def parse_type_or_universal!
|
|
218
|
+
parse_namespace_prefix! if namespace_prefix_ahead?
|
|
219
|
+
if peek == "*"
|
|
220
|
+
advance
|
|
221
|
+
elsif ident_start?
|
|
222
|
+
consume_ident!
|
|
223
|
+
else
|
|
224
|
+
fail!("expected type selector")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Is there a namespace prefix (`*|`, `|`, `ident|`) at the cursor, as
|
|
229
|
+
# distinct from a `||` column combinator?
|
|
230
|
+
def namespace_prefix_ahead?
|
|
231
|
+
if peek == "*"
|
|
232
|
+
return peek(1) == "|" && peek(2) != "|"
|
|
233
|
+
end
|
|
234
|
+
if peek == "|"
|
|
235
|
+
return peek(1) != "|"
|
|
236
|
+
end
|
|
237
|
+
if ident_start?
|
|
238
|
+
# Scan the ident, then check for a single '|' (not '||').
|
|
239
|
+
j = scan_ident_end(@i)
|
|
240
|
+
return @s[j] == "|" && @s[j + 1] != "|"
|
|
241
|
+
end
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ns-prefix := (<ident> | '*')? '|' — any *named* prefix is undeclared.
|
|
246
|
+
def parse_namespace_prefix!
|
|
247
|
+
if peek == "*"
|
|
248
|
+
advance
|
|
249
|
+
elsif peek == "|"
|
|
250
|
+
# empty (no-namespace) prefix
|
|
251
|
+
elsif ident_start?
|
|
252
|
+
consume_ident!
|
|
253
|
+
fail_undeclared_namespace!
|
|
254
|
+
else
|
|
255
|
+
fail!("invalid namespace prefix")
|
|
256
|
+
end
|
|
257
|
+
fail!("expected '|' in namespace prefix") unless peek == "|"
|
|
258
|
+
advance
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def fail_undeclared_namespace!
|
|
262
|
+
raise InvalidSelector, "undeclared namespace"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# id := '#' <name> (a hash token; `#` alone or `#` + non-name invalid)
|
|
266
|
+
def parse_id!
|
|
267
|
+
advance # consume '#'
|
|
268
|
+
fail!("invalid id") unless name_char_start?(allow_leading_digit: true)
|
|
269
|
+
consume_name!
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# class := '.' <ident>
|
|
273
|
+
def parse_class!
|
|
274
|
+
advance # consume '.'
|
|
275
|
+
fail!("invalid class") unless ident_start?
|
|
276
|
+
consume_ident!
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# attribute := '[' WS? [<ns-prefix>]? <ident> WS?
|
|
280
|
+
# ( <matcher> WS? (<ident> | <string>) WS? <flag>? WS? )? ']'
|
|
281
|
+
def parse_attribute!
|
|
282
|
+
advance # consume '['
|
|
283
|
+
skip_ws
|
|
284
|
+
parse_namespace_prefix! if attribute_namespace_prefix_ahead?
|
|
285
|
+
fail!("invalid attribute name") unless ident_start?
|
|
286
|
+
consume_ident!
|
|
287
|
+
skip_ws
|
|
288
|
+
unless peek == "]"
|
|
289
|
+
consume_attr_matcher!
|
|
290
|
+
skip_ws
|
|
291
|
+
consume_attr_value!
|
|
292
|
+
skip_ws
|
|
293
|
+
consume_attr_flag! if ident_start?
|
|
294
|
+
skip_ws
|
|
295
|
+
end
|
|
296
|
+
# Per CSS tokenizing, EOF implicitly closes an open `[` — so a trailing
|
|
297
|
+
# unclosed attribute selector (`[align="center"`) is still valid.
|
|
298
|
+
return if eof?
|
|
299
|
+
|
|
300
|
+
fail!("unclosed attribute selector") unless peek == "]"
|
|
301
|
+
advance
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Inside `[...]`, a namespace prefix precedes the attribute name. `*|` is
|
|
305
|
+
# any-namespace; a bare `|`; a named prefix is undeclared.
|
|
306
|
+
def attribute_namespace_prefix_ahead?
|
|
307
|
+
if peek == "*"
|
|
308
|
+
return peek(1) == "|"
|
|
309
|
+
end
|
|
310
|
+
if peek == "|"
|
|
311
|
+
return true
|
|
312
|
+
end
|
|
313
|
+
if ident_start?
|
|
314
|
+
j = scan_ident_end(@i)
|
|
315
|
+
return @s[j] == "|" && @s[j + 1] != "="
|
|
316
|
+
end
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def consume_attr_matcher!
|
|
321
|
+
c = peek
|
|
322
|
+
if "~|^$*".include?(c)
|
|
323
|
+
advance
|
|
324
|
+
fail!("invalid attribute matcher") unless peek == "="
|
|
325
|
+
advance
|
|
326
|
+
elsif c == "="
|
|
327
|
+
advance
|
|
328
|
+
else
|
|
329
|
+
fail!("invalid attribute selector")
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def consume_attr_value!
|
|
334
|
+
if peek == '"' || peek == "'"
|
|
335
|
+
consume_string!
|
|
336
|
+
elsif ident_start?
|
|
337
|
+
consume_ident!
|
|
338
|
+
else
|
|
339
|
+
fail!("invalid attribute value")
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The trailing case-sensitivity flag: a single i/I/s/S, then only WS or ].
|
|
344
|
+
def consume_attr_flag!
|
|
345
|
+
flag = peek
|
|
346
|
+
fail!("invalid attribute flag") unless %w[i I s S].include?(flag)
|
|
347
|
+
advance
|
|
348
|
+
fail!("invalid attribute flag") unless eof? || WS.include?(peek) || peek == "]"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The four pseudo-elements that also accept the legacy one-colon syntax;
|
|
352
|
+
# written with `:` they are still pseudo-elements (match no element).
|
|
353
|
+
LEGACY_PSEUDO_ELEMENTS = %w[before after first-line first-letter].to_set.freeze
|
|
354
|
+
|
|
355
|
+
# pseudo := '::' <pseudo-element> | ':' (<pseudo-class> | <function>).
|
|
356
|
+
# Returns true when this is a pseudo-element (so a compound ending here
|
|
357
|
+
# matches no element).
|
|
358
|
+
def parse_pseudo!
|
|
359
|
+
advance # first ':'
|
|
360
|
+
if peek == ":"
|
|
361
|
+
advance # pseudo-element '::'
|
|
362
|
+
parse_pseudo_element!
|
|
363
|
+
true
|
|
364
|
+
else
|
|
365
|
+
parse_pseudo_class!
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def parse_pseudo_element!
|
|
370
|
+
fail!("invalid pseudo-element") unless ident_start?
|
|
371
|
+
name = consume_ident!.downcase
|
|
372
|
+
if peek == "("
|
|
373
|
+
consume_function_args!(name, pseudo_element: true)
|
|
374
|
+
else
|
|
375
|
+
fail!("unknown pseudo-element '#{name}'") unless KNOWN_PSEUDO_ELEMENTS.include?(name)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Returns true when the `:name` is actually a legacy pseudo-element.
|
|
380
|
+
def parse_pseudo_class!
|
|
381
|
+
fail!("invalid pseudo-class") unless ident_start?
|
|
382
|
+
name = consume_ident!.downcase
|
|
383
|
+
if peek == "("
|
|
384
|
+
consume_function_args!(name, pseudo_element: false)
|
|
385
|
+
false
|
|
386
|
+
else
|
|
387
|
+
fail!("unknown pseudo-class '#{name}'") unless KNOWN_PSEUDOS.include?(name)
|
|
388
|
+
LEGACY_PSEUDO_ELEMENTS.include?(name)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Validate `name(...)` per the function's argument grammar.
|
|
393
|
+
def consume_function_args!(name, pseudo_element:)
|
|
394
|
+
advance # consume '('
|
|
395
|
+
skip_ws
|
|
396
|
+
if pseudo_element
|
|
397
|
+
# ::slotted(<compound>), ::part(<ident>+), ::cue(<selector>), …
|
|
398
|
+
case name
|
|
399
|
+
when "slotted" then parse_complex_selector!
|
|
400
|
+
when "part" then consume_ident_sequence!
|
|
401
|
+
else parse_complex_selector!
|
|
402
|
+
end
|
|
403
|
+
elsif SELECTOR_LIST_FUNCTIONS.include?(name)
|
|
404
|
+
parse_inner_selector_list!
|
|
405
|
+
elsif NTH_FUNCTIONS.include?(name)
|
|
406
|
+
consume_nth!
|
|
407
|
+
elsif IDENT_FUNCTIONS.include?(name)
|
|
408
|
+
consume_ident_sequence!
|
|
409
|
+
elsif NESTED_SELECTOR_FUNCTIONS.include?(name)
|
|
410
|
+
parse_inner_selector_list!
|
|
411
|
+
elsif KNOWN_PSEUDOS.include?(name)
|
|
412
|
+
# A known pseudo used functionally we don't model the args of — accept
|
|
413
|
+
# a balanced, non-empty argument run.
|
|
414
|
+
consume_balanced_until_close!
|
|
415
|
+
else
|
|
416
|
+
fail!("unknown functional pseudo-class '#{name}'")
|
|
417
|
+
end
|
|
418
|
+
skip_ws
|
|
419
|
+
# EOF implicitly closes an open `(` (`::slotted(foo`), like the `[`
|
|
420
|
+
# case above.
|
|
421
|
+
return if eof?
|
|
422
|
+
|
|
423
|
+
fail!("unclosed pseudo-class function") unless peek == ")"
|
|
424
|
+
advance
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# A selector list inside :not()/:is()/:where()/:has() — `:has` allows a
|
|
428
|
+
# leading combinator (relative selector), the others do not.
|
|
429
|
+
def parse_inner_selector_list!
|
|
430
|
+
skip_ws
|
|
431
|
+
consume_combinator! if combinator_char?(peek) # tolerate relative selectors
|
|
432
|
+
skip_ws
|
|
433
|
+
parse_complex_selector!
|
|
434
|
+
while peek == ","
|
|
435
|
+
advance
|
|
436
|
+
skip_ws
|
|
437
|
+
consume_combinator! if combinator_char?(peek)
|
|
438
|
+
skip_ws
|
|
439
|
+
parse_complex_selector!
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# An+B microsyntax (`2n`, `-3n+1`, `odd`, `even`, `5`, `n`).
|
|
444
|
+
def consume_nth!
|
|
445
|
+
word = peek_word.downcase
|
|
446
|
+
if word == "odd" || word == "even"
|
|
447
|
+
consume_ident!
|
|
448
|
+
return
|
|
449
|
+
end
|
|
450
|
+
consumed = false
|
|
451
|
+
if peek == "+" || peek == "-"
|
|
452
|
+
advance
|
|
453
|
+
consumed = true
|
|
454
|
+
end
|
|
455
|
+
while digit?(peek)
|
|
456
|
+
advance
|
|
457
|
+
consumed = true
|
|
458
|
+
end
|
|
459
|
+
if peek == "n" || peek == "N"
|
|
460
|
+
advance
|
|
461
|
+
consumed = true
|
|
462
|
+
skip_ws
|
|
463
|
+
if peek == "+" || peek == "-"
|
|
464
|
+
advance
|
|
465
|
+
skip_ws
|
|
466
|
+
fail!("invalid An+B") unless digit?(peek)
|
|
467
|
+
advance while digit?(peek)
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
fail!("invalid An+B expression") unless consumed
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def consume_ident_sequence!
|
|
474
|
+
fail!("expected identifier") unless ident_start?
|
|
475
|
+
consume_ident!
|
|
476
|
+
loop do
|
|
477
|
+
skip_ws
|
|
478
|
+
break unless ident_start? || peek == ","
|
|
479
|
+
|
|
480
|
+
advance if peek == ","
|
|
481
|
+
skip_ws
|
|
482
|
+
consume_ident! if ident_start?
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Consume a balanced run up to the matching ')' (for pseudo functions we
|
|
487
|
+
# don't model). Nested ()/[] are balanced; the run must be non-empty.
|
|
488
|
+
def consume_balanced_until_close!
|
|
489
|
+
depth = 0
|
|
490
|
+
started = false
|
|
491
|
+
until eof?
|
|
492
|
+
c = peek
|
|
493
|
+
break if c == ")" && depth.zero?
|
|
494
|
+
|
|
495
|
+
started = true
|
|
496
|
+
if c == "(" || c == "["
|
|
497
|
+
depth += 1
|
|
498
|
+
elsif c == ")" || c == "]"
|
|
499
|
+
depth -= 1
|
|
500
|
+
elsif c == '"' || c == "'"
|
|
501
|
+
consume_string!
|
|
502
|
+
next
|
|
503
|
+
end
|
|
504
|
+
advance
|
|
505
|
+
end
|
|
506
|
+
fail!("empty function arguments") unless started
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# ---- token helpers -------------------------------------------------
|
|
510
|
+
|
|
511
|
+
def consume_string!
|
|
512
|
+
quote = peek
|
|
513
|
+
advance
|
|
514
|
+
until eof?
|
|
515
|
+
c = peek
|
|
516
|
+
if c == "\\"
|
|
517
|
+
advance
|
|
518
|
+
advance unless eof?
|
|
519
|
+
next
|
|
520
|
+
elsif c == quote
|
|
521
|
+
advance
|
|
522
|
+
return
|
|
523
|
+
elsif c == "\n"
|
|
524
|
+
fail!("newline in string")
|
|
525
|
+
end
|
|
526
|
+
advance
|
|
527
|
+
end
|
|
528
|
+
# EOF implicitly closes an open string (CSS tokenizing); only a raw
|
|
529
|
+
# newline inside a string is a parse error.
|
|
530
|
+
nil
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Consume an identifier (assumes ident_start?). Returns the text.
|
|
534
|
+
def consume_ident!
|
|
535
|
+
start = @i
|
|
536
|
+
# leading hyphen(s)
|
|
537
|
+
advance if peek == "-"
|
|
538
|
+
if peek == "\\"
|
|
539
|
+
consume_escape!
|
|
540
|
+
elsif ident_letter?(peek)
|
|
541
|
+
advance
|
|
542
|
+
else
|
|
543
|
+
fail!("invalid identifier")
|
|
544
|
+
end
|
|
545
|
+
consume_name_rest!
|
|
546
|
+
@s[start...@i]
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Consume a name (id token body): like an ident but may start with a
|
|
550
|
+
# digit / hyphen sequence.
|
|
551
|
+
def consume_name!
|
|
552
|
+
start = @i
|
|
553
|
+
consume_name_rest!(require_one: true)
|
|
554
|
+
@s[start...@i]
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def consume_name_rest!(require_one: false)
|
|
558
|
+
count = 0
|
|
559
|
+
loop do
|
|
560
|
+
c = peek
|
|
561
|
+
if c == "\\"
|
|
562
|
+
consume_escape!
|
|
563
|
+
count += 1
|
|
564
|
+
elsif name_char?(c)
|
|
565
|
+
advance
|
|
566
|
+
count += 1
|
|
567
|
+
else
|
|
568
|
+
break
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
fail!("empty name") if require_one && count.zero?
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def consume_escape!
|
|
575
|
+
advance # backslash
|
|
576
|
+
fail!("trailing backslash") if eof?
|
|
577
|
+
advance # at least one char follows
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# ---- character classification --------------------------------------
|
|
581
|
+
|
|
582
|
+
def ident_start?
|
|
583
|
+
c = peek
|
|
584
|
+
return false if c.nil?
|
|
585
|
+
return true if ident_letter?(c)
|
|
586
|
+
return true if c == "\\" && !eof?(1)
|
|
587
|
+
# leading '-' is an ident start if followed by ident-letter / '-' / esc
|
|
588
|
+
if c == "-"
|
|
589
|
+
nxt = peek(1)
|
|
590
|
+
return !nxt.nil? && (ident_letter?(nxt) || nxt == "-" || nxt == "\\")
|
|
591
|
+
end
|
|
592
|
+
false
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# A letter, underscore, or non-ASCII (>= U+0080) start char.
|
|
596
|
+
def ident_letter?(c)
|
|
597
|
+
return false if c.nil?
|
|
598
|
+
|
|
599
|
+
c.match?(/[A-Za-z_]/) || c.ord >= 0x80
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def name_char?(c)
|
|
603
|
+
return false if c.nil?
|
|
604
|
+
|
|
605
|
+
c.match?(/[A-Za-z0-9_\-]/) || c.ord >= 0x80
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def name_char_start?(allow_leading_digit: false)
|
|
609
|
+
c = peek
|
|
610
|
+
return false if c.nil?
|
|
611
|
+
return true if c == "\\" && !eof?(1)
|
|
612
|
+
return true if name_char?(c) && (allow_leading_digit || !digit?(c))
|
|
613
|
+
|
|
614
|
+
false
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def digit?(c) = !c.nil? && c >= "0" && c <= "9"
|
|
618
|
+
|
|
619
|
+
# Index just past the identifier starting at `from` (no validation).
|
|
620
|
+
def scan_ident_end(from)
|
|
621
|
+
j = from
|
|
622
|
+
j += 1 if @s[j] == "-"
|
|
623
|
+
while (ch = @s[j])
|
|
624
|
+
if ch == "\\"
|
|
625
|
+
j += 2
|
|
626
|
+
elsif ch.match?(/[A-Za-z0-9_\-]/) || ch.ord >= 0x80
|
|
627
|
+
j += 1
|
|
628
|
+
else
|
|
629
|
+
break
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
j
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# ---- cursor --------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
def peek(offset = 0) = @s[@i + offset]
|
|
638
|
+
|
|
639
|
+
def peek_word
|
|
640
|
+
j = @i
|
|
641
|
+
j += 1 while j < @n && @s[j].match?(/[A-Za-z]/)
|
|
642
|
+
@s[@i...j]
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def advance = @i += 1
|
|
646
|
+
|
|
647
|
+
def skip_ws
|
|
648
|
+
moved = false
|
|
649
|
+
while !eof? && WS.include?(peek)
|
|
650
|
+
advance
|
|
651
|
+
moved = true
|
|
652
|
+
end
|
|
653
|
+
moved
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def eof?(offset = 0) = (@i + offset) >= @n
|
|
657
|
+
|
|
658
|
+
def fail!(message)
|
|
659
|
+
raise InvalidSelector, message
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|