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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. 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