Class: Dommy::Internal::SelectorParser::Parser

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/internal/selector_parser.rb

Overview

Recursive-descent parser over a character buffer. Methods raise InvalidSelector on the first grammar violation.

Constant Summary collapse

WS =
" \t\r\n\f"
LEGACY_PSEUDO_ELEMENTS =

The four pseudo-elements that also accept the legacy one-colon syntax; written with : they are still pseudo-elements (match no element).

%w[before after first-line first-letter].to_set.freeze
AN_PLUS_B =

css-syntax An+B: <integer> and n form a single token (3n), so whitespace between them (3 n) is invalid; whitespace around the operator before the B part (3n + 1) is fine.

/\A([+-])?[ \t\r\n\f]*(\d+)?n(?:[ \t\r\n\f]*([+-])[ \t\r\n\f]*(\d+))?\z/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(string, in_has: false, namespaces: nil) ⇒ Parser

Returns a new instance of Parser.



139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/dommy/internal/selector_parser.rb', line 139

def initialize(string, in_has: false, namespaces: nil)
  @s = string
  @i = 0
  @n = string.length
  @clauses = []
  # True while parsing the argument of a `:has()` — a structurally
  # nested `:has()` is invalid (string occurrences inside quoted
  # attribute values are fine).
  @in_has = in_has
  # prefix String => URI (and :default => default-namespace URI) from a
  # stylesheet's @namespace rules; {} on the DOM querySelector path.
  @namespaces = namespaces || {}
  @default_namespace = @namespaces[:default]
end

Instance Attribute Details

#clausesObject (readonly)

Per top-level clause: { text:, pseudo_subject: } where pseudo_subject is true when the clause's subject (rightmost compound) is a pseudo-element (::before, :first-line) — such a clause matches no element.



137
138
139
# File 'lib/dommy/internal/selector_parser.rb', line 137

def clauses
  @clauses
end

Instance Method Details

#advanceObject



834
# File 'lib/dommy/internal/selector_parser.rb', line 834

def advance = @i += 1

#attribute_namespace_prefix_ahead?Boolean

Inside [...], a namespace prefix precedes the attribute name. *| is any-namespace; a bare |; a named prefix is undeclared.

Returns:

  • (Boolean)


393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/dommy/internal/selector_parser.rb', line 393

def attribute_namespace_prefix_ahead?
  if peek == "*"
    return peek(1) == "|"
  end
  if peek == "|"
    return true
  end
  if ident_start?
    j = scan_ident_end(@i)
    return @s[j] == "|" && @s[j + 1] != "="
  end
  false
end

#combinator_char?(c) ⇒ Boolean

Returns:

  • (Boolean)


233
234
235
# File 'lib/dommy/internal/selector_parser.rb', line 233

def combinator_char?(c)
  c == ">" || c == "+" || c == "~" || (c == "|" && peek(1) == "|")
end

#consume_attr_flag!Object

The trailing case-sensitivity flag: a single i/I/s/S, then only WS or ].



433
434
435
436
437
438
439
# File 'lib/dommy/internal/selector_parser.rb', line 433

def consume_attr_flag!
  flag = peek
  fail!("invalid attribute flag") unless %w[i I s S].include?(flag)
  advance
  fail!("invalid attribute flag") unless eof? || WS.include?(peek) || peek == "]"
  flag.downcase
end

#consume_attr_matcher!Object



407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/dommy/internal/selector_parser.rb', line 407

def consume_attr_matcher!
  c = peek
  if "~|^$*".include?(c)
    advance
    fail!("invalid attribute matcher") unless peek == "="
    advance
    "#{c}="
  elsif c == "="
    advance
    "="
  else
    fail!("invalid attribute selector")
  end
end

#consume_attr_value!Object



422
423
424
425
426
427
428
429
430
# File 'lib/dommy/internal/selector_parser.rb', line 422

def consume_attr_value!
  if peek == '"' || peek == "'"
    consume_string!
  elsif ident_start?
    consume_ident!
  else
    fail!("invalid attribute value")
  end
end

#consume_combinator!Object

One explicit combinator token: > , + , ~ , >> , || .



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/dommy/internal/selector_parser.rb', line 208

def consume_combinator!
  c = peek
  case c
  when ">"
    advance
    if peek == ">" # legacy descendant `>>`
      advance
      :descendant
    else
      :child
    end
  when "+", "~"
    advance
    fail!("invalid combinator") if peek == c # `++`, `~~`
    c == "+" ? :next_sibling : :subsequent_sibling
  when "|"
    fail!("invalid combinator") unless peek(1) == "|"
    advance
    advance
    :column
  else
    fail!("invalid combinator #{c.inspect}")
  end
end

#consume_escape!Object



714
715
716
717
718
719
720
721
722
723
724
725
726
727
# File 'lib/dommy/internal/selector_parser.rb', line 714

def consume_escape!
  advance # backslash
  fail!("trailing backslash") if eof?
  if hex_digit?(peek)
    count = 0
    while count < 6 && hex_digit?(peek)
      advance
      count += 1
    end
    advance if !eof? && WS.include?(peek)
  else
    advance # at least one char follows
  end
end

#consume_function_args!(name, pseudo_element:) ⇒ Object

Validate name(...) per the function's argument grammar.



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/dommy/internal/selector_parser.rb', line 487

def consume_function_args!(name, pseudo_element:)
  advance # consume '('
  # :is()/:where()/:matches() take a forgiving selector list, which may be
  # empty (matches nothing); other functional pseudos require an argument.
  arg = consume_function_argument_source(allow_empty: %w[is where matches].include?(name))
  arg_parser = Parser.new(arg, in_has: @in_has, namespaces: @namespaces)
  if pseudo_element
    # ::slotted(<compound>), ::part(<ident>+), ::cue(<selector>), …
    case name
    when "slotted" then arg_parser.parse_complex_selector!
    when "part" then arg.split(/\s+/).reject(&:empty?)
    else arg_parser.parse_complex_selector!
    end
  elsif %w[is where matches].include?(name)
    parse_selector_argument_list(arg, forgiving: true)
  elsif name == "not"
    parse_selector_argument_list(arg, forgiving: false)
  elsif name == "has"
    fail!("nested :has() is invalid") if @in_has
    rels = parse_relative_selector_argument_list(arg)
    fail!("pseudo-element in :has() is invalid") if rels.any? { |r| r.complex.pseudo_element? }
    rels
  elsif NTH_FUNCTIONS.include?(name)
    parse_nth_argument(arg, allow_of: %w[nth-child nth-last-child].include?(name))
  elsif IDENT_FUNCTIONS.include?(name)
    parse_ident_argument(arg)
  elsif NESTED_SELECTOR_FUNCTIONS.include?(name)
    parse_selector_argument_list(arg, forgiving: false)
  elsif KNOWN_PSEUDOS.include?(name)
    # A known pseudo used functionally we don't model the args of — accept
    # a balanced, non-empty argument run.
    fail!("empty function arguments") if arg.strip.empty?
    arg.strip
  else
    fail!("unknown functional pseudo-class '#{name}'")
  end
end

#consume_function_argument_source(allow_empty: false) ⇒ Object



525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# File 'lib/dommy/internal/selector_parser.rb', line 525

def consume_function_argument_source(allow_empty: false)
  skip_ws
  start = @i
  depth = 0
  until eof?
    c = peek
    break if c == ")" && depth.zero?

    if c == "(" || c == "["
      depth += 1
    elsif c == ")" || c == "]"
      depth -= 1
    elsif c == '"' || c == "'"
      consume_string!
      next
    end
    advance
  end
  arg = @s[start...@i].to_s.strip
  fail!("empty function arguments") if arg.empty? && !allow_empty
  advance if peek == ")"
  arg
end

#consume_ident!Object

Consume an identifier (assumes ident_start?). Returns the text.



674
675
676
677
678
679
680
681
682
683
684
685
686
687
# File 'lib/dommy/internal/selector_parser.rb', line 674

def consume_ident!
  start = @i
  # leading hyphen(s)
  advance if peek == "-"
  if peek == "\\"
    consume_escape!
  elsif ident_letter?(peek)
    advance
  else
    fail!("invalid identifier")
  end
  consume_name_rest!
  decode_css_identifier(@s[start...@i])
end

#consume_name!Object

Consume a name (id token body): like an ident but may start with a digit / hyphen sequence.



691
692
693
694
695
# File 'lib/dommy/internal/selector_parser.rb', line 691

def consume_name!
  start = @i
  consume_name_rest!(require_one: true)
  decode_css_identifier(@s[start...@i])
end

#consume_name_rest!(require_one: false) ⇒ Object



697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
# File 'lib/dommy/internal/selector_parser.rb', line 697

def consume_name_rest!(require_one: false)
  count = 0
  loop do
    c = peek
    if c == "\\"
      consume_escape!
      count += 1
    elsif name_char?(c)
      advance
      count += 1
    else
      break
    end
  end
  fail!("empty name") if require_one && count.zero?
end

#consume_string!Object

---- token helpers -------------------------------------------------



647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
# File 'lib/dommy/internal/selector_parser.rb', line 647

def consume_string!
  quote = peek
  value = +""
  advance
  until eof?
    c = peek
    if c == "\\"
      start = @i
      consume_escape!
      escaped = @s[start...@i]
      value << decode_css_identifier(escaped)
      next
    elsif c == quote
      advance
      return value
    elsif c == "\n"
      fail!("newline in string")
    end
    value << c
    advance
  end
  # EOF implicitly closes an open string (CSS tokenizing); only a raw
  # newline inside a string is a parse error.
  value
end

#decode_css_identifier(value) ⇒ Object



729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
# File 'lib/dommy/internal/selector_parser.rb', line 729

def decode_css_identifier(value)
  out = +""
  i = 0
  while i < value.length
    c = value[i]
    unless c == "\\"
      out << c
      i += 1
      next
    end

    i += 1
    break if i >= value.length

    hex = value[i, 6].to_s[/\A[0-9A-Fa-f]{1,6}/]
    if hex
      codepoint = hex.to_i(16)
      out << (codepoint.zero? ? "\uFFFD" : codepoint.chr(Encoding::UTF_8))
      i += hex.length
      i += 1 if i < value.length && WS.include?(value[i])
    else
      out << value[i]
      i += 1
    end
  end
  out
end

#digit?(c) ⇒ Boolean

Returns:

  • (Boolean)


794
# File 'lib/dommy/internal/selector_parser.rb', line 794

def digit?(c) = !c.nil? && c >= "0" && c <= "9"

#eof?(offset = 0) ⇒ Boolean

Returns:

  • (Boolean)


845
# File 'lib/dommy/internal/selector_parser.rb', line 845

def eof?(offset = 0) = (@i + offset) >= @n

#fail!(message) ⇒ Object

Raises:



847
848
849
# File 'lib/dommy/internal/selector_parser.rb', line 847

def fail!(message)
  raise InvalidSelector, message
end

#fail_undeclared_namespace!Object

Raises:



344
345
346
# File 'lib/dommy/internal/selector_parser.rb', line 344

def fail_undeclared_namespace!
  raise InvalidSelector, "undeclared namespace"
end

#hex_digit?(c) ⇒ Boolean

Returns:

  • (Boolean)


796
# File 'lib/dommy/internal/selector_parser.rb', line 796

def hex_digit?(c) = !c.nil? && c.match?(/[0-9A-Fa-f]/)

#ident_letter?(c) ⇒ Boolean

A letter, underscore, or non-ASCII (>= U+0080) start char.

Returns:

  • (Boolean)


773
774
775
776
777
# File 'lib/dommy/internal/selector_parser.rb', line 773

def ident_letter?(c)
  return false if c.nil?

  c.match?(/[A-Za-z_]/) || c.ord >= 0x80
end

#ident_start?Boolean

---- character classification --------------------------------------

Returns:

  • (Boolean)


759
760
761
762
763
764
765
766
767
768
769
770
# File 'lib/dommy/internal/selector_parser.rb', line 759

def ident_start?
  c = peek
  return false if c.nil?
  return true if ident_letter?(c)
  return true if c == "\\" && !eof?(1)
  # leading '-' is an ident start if followed by ident-letter / '-' / esc
  if c == "-"
    nxt = peek(1)
    return !nxt.nil? && (ident_letter?(nxt) || nxt == "-" || nxt == "\\")
  end
  false
end

#name_char?(c) ⇒ Boolean

Returns:

  • (Boolean)


779
780
781
782
783
# File 'lib/dommy/internal/selector_parser.rb', line 779

def name_char?(c)
  return false if c.nil?

  c.match?(/[A-Za-z0-9_\-]/) || c.ord >= 0x80
end

#name_char_start?(allow_leading_digit: false) ⇒ Boolean

Returns:

  • (Boolean)


785
786
787
788
789
790
791
792
# File 'lib/dommy/internal/selector_parser.rb', line 785

def name_char_start?(allow_leading_digit: false)
  c = peek
  return false if c.nil?
  return true if c == "\\" && !eof?(1)
  return true if name_char?(c) && (allow_leading_digit || !digit?(c))

  false
end

#namespace_prefix_ahead?Boolean

Is there a namespace prefix (*|, |, ident|) at the cursor, as distinct from a || column combinator?

Returns:

  • (Boolean)


308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/dommy/internal/selector_parser.rb', line 308

def namespace_prefix_ahead?
  if peek == "*"
    return peek(1) == "|" && peek(2) != "|"
  end
  if peek == "|"
    return peek(1) != "|"
  end
  if ident_start?
    # Scan the ident, then check for a single '|' (not '||').
    j = scan_ident_end(@i)
    return @s[j] == "|" && @s[j + 1] != "|"
  end
  false
end

#parse_an_plus_b(source) ⇒ Object



625
626
627
628
629
630
631
632
633
634
635
636
637
# File 'lib/dommy/internal/selector_parser.rb', line 625

def parse_an_plus_b(source)
  s = source.strip.downcase
  return [2, 1] if s == "odd"
  return [2, 0] if s == "even"
  return [0, Integer(s.delete(" \t\r\n\f"))] if s.match?(/\A[+-]?[ \t\r\n\f]*\d+\z/)
  if (m = s.match(AN_PLUS_B))
    a = (m[2] ? m[2].to_i : 1)
    a = -a if m[1] == "-"
    b = m[3] ? Integer("#{m[3]}#{m[4]}") : 0
    return [a, b]
  end
  fail!("invalid An+B expression")
end

#parse_attribute!Object

attribute := '[' WS? []? WS? ( WS? ( | ) WS? ? WS? )? ']'



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/dommy/internal/selector_parser.rb', line 364

def parse_attribute!
  advance # consume '['
  skip_ws
  ns = attribute_namespace_prefix_ahead? ? parse_namespace_prefix! : nil
  fail!("invalid attribute name") unless ident_start?
  name = consume_ident!
  skip_ws
  matcher = nil
  value = nil
  flag = nil
  unless peek == "]"
    matcher = consume_attr_matcher!
    skip_ws
    value = consume_attr_value!
    skip_ws
    flag = consume_attr_flag! if ident_start?
    skip_ws
  end
  # Per CSS tokenizing, EOF implicitly closes an open `[` — so a trailing
  # unclosed attribute selector (`[align="center"`) is still valid.
  return SelectorAST::AttributeSelector.new(ns, name, matcher, value, flag) if eof?

  fail!("unclosed attribute selector") unless peek == "]"
  advance
  SelectorAST::AttributeSelector.new(ns, name, matcher, value, flag)
end

#parse_class!Object

class := '.'



356
357
358
359
360
# File 'lib/dommy/internal/selector_parser.rb', line 356

def parse_class!
  advance # consume '.'
  fail!("invalid class") unless ident_start?
  SelectorAST::ClassSelector.new(consume_ident!)
end

#parse_complex_selector!Object

complex := ( )* combinator is one of > + ~ >> || or descendant (whitespace). Returns whether the SUBJECT (last) compound is a pseudo-element.



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/dommy/internal/selector_parser.rb', line 184

def parse_complex_selector!
  parts = [SelectorAST::Part.new(nil, parse_compound_selector!)]
  loop do
    had_ws = skip_ws
    # `)` ends a complex selector nested in a functional pseudo
    # (`:not(div)`); `,` / EOF end one at the top level.
    break if eof? || peek == "," || peek == ")"

    if combinator_char?(peek)
      combinator = consume_combinator!
      skip_ws
      fail!("dangling combinator") if eof? || peek == "," || combinator_char?(peek)
      parts << SelectorAST::Part.new(combinator, parse_compound_selector!)
    elsif had_ws
      # Descendant combinator (whitespace) — next must be a compound.
      parts << SelectorAST::Part.new(:descendant, parse_compound_selector!)
    else
      fail!("unexpected #{peek.inspect}")
    end
  end
  SelectorAST::ComplexSelector.new(parts)
end

#parse_compound_selector!Object

compound := [ | ]? * with at least one simple selector. A type/universal, if present, comes first. Returns whether the compound includes a pseudo-element (always the last token).



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/dommy/internal/selector_parser.rb', line 240

def parse_compound_selector!
  saw_any = false
  type = nil
  subclasses = []
  pseudo_element = nil
  # Optional leading type/universal (may carry a namespace prefix).
  if type_start?
    type = parse_type_or_universal!
    saw_any = true
  end
  loop do
    c = peek
    break unless c == "#" || c == "." || c == "[" || c == ":"

    # A pseudo-element ends the compound: `::before.foo`,
    # `::before:hover`, `::before::after` are all invalid.
    fail!("selector after pseudo-element") if pseudo_element

    case c
    when "#"
      subclasses << parse_id!
    when "."
      subclasses << parse_class!
    when "["
      subclasses << parse_attribute!
    when ":"
      parsed = parse_pseudo!
      if parsed.is_a?(SelectorAST::PseudoElement)
        pseudo_element = parsed
      else
        subclasses << parsed
      end
    end
    saw_any = true
  end
  fail!("empty compound selector") unless saw_any

  SelectorAST::CompoundSelector.new(type, subclasses, pseudo_element)
end

#parse_id!Object

id := '#' (a hash token; # alone or # + non-name invalid)



349
350
351
352
353
# File 'lib/dommy/internal/selector_parser.rb', line 349

def parse_id!
  advance # consume '#'
  fail!("invalid id") unless name_char_start?(allow_leading_digit: true)
  SelectorAST::IdSelector.new(consume_name!)
end

#parse_ident_argument(source) ⇒ Object



639
640
641
642
643
# File 'lib/dommy/internal/selector_parser.rb', line 639

def parse_ident_argument(source)
  parts = source.split(/\s*,\s*|\s+/).reject(&:empty?)
  fail!("expected identifier") if parts.empty?
  parts.length == 1 ? parts.first : parts
end

#parse_namespace_prefix!Object

ns-prefix := ( | '*')? '|' — any named prefix is undeclared.



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/dommy/internal/selector_parser.rb', line 324

def parse_namespace_prefix!
  ns = nil
  if peek == "*"
    advance
    ns = :any
  elsif peek == "|"
    # empty (no-namespace) prefix
    ns = :none
  elsif ident_start?
    prefix = consume_ident!
    ns = @namespaces[prefix]
    fail_undeclared_namespace! unless ns
  else
    fail!("invalid namespace prefix")
  end
  fail!("expected '|' in namespace prefix") unless peek == "|"
  advance
  ns
end

#parse_nth_argument(source, allow_of:) ⇒ Object



606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'lib/dommy/internal/selector_parser.rb', line 606

def parse_nth_argument(source, allow_of:)
  expr = source.strip
  of_list = nil
  if allow_of && (match = expr.match(/\s+of\s+/i))
    anb = expr[0...match.begin(0)].strip
    selectors = expr[match.end(0)..].strip
    of_list = parse_selector_argument_list(selectors, forgiving: false)
  else
    anb = expr
  end
  a, b = parse_an_plus_b(anb)
  SelectorAST::NthExpression.new(a, b, of_list)
end

#parse_pseudo!Object

pseudo := '::' | ':' ( | ). Returns true when this is a pseudo-element (so a compound ending here matches no element).



448
449
450
451
452
453
454
455
456
# File 'lib/dommy/internal/selector_parser.rb', line 448

def parse_pseudo!
  advance # first ':'
  if peek == ":"
    advance # pseudo-element '::'
    parse_pseudo_element!
  else
    parse_pseudo_class!
  end
end

#parse_pseudo_class!Object

Returns true when the :name is actually a legacy pseudo-element.



471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/dommy/internal/selector_parser.rb', line 471

def parse_pseudo_class!
  fail!("invalid pseudo-class") unless ident_start?
  name = consume_ident!.downcase
  if peek == "("
    SelectorAST::PseudoClass.new(name, consume_function_args!(name, pseudo_element: false))
  else
    fail!("unknown pseudo-class '#{name}'") unless KNOWN_PSEUDOS.include?(name)
    if LEGACY_PSEUDO_ELEMENTS.include?(name)
      SelectorAST::PseudoElement.new(name, nil)
    else
      SelectorAST::PseudoClass.new(name, nil)
    end
  end
end

#parse_pseudo_element!Object



458
459
460
461
462
463
464
465
466
467
468
# File 'lib/dommy/internal/selector_parser.rb', line 458

def parse_pseudo_element!
  fail!("invalid pseudo-element") unless ident_start?
  name = consume_ident!.downcase
  argument = nil
  if peek == "("
    argument = consume_function_args!(name, pseudo_element: true)
  else
    fail!("unknown pseudo-element '#{name}'") unless KNOWN_PSEUDO_ELEMENTS.include?(name)
  end
  SelectorAST::PseudoElement.new(name, argument)
end

#parse_relative_selector!Object



571
572
573
574
575
576
577
578
579
# File 'lib/dommy/internal/selector_parser.rb', line 571

def parse_relative_selector!
  skip_ws
  leading = combinator_char?(peek) ? consume_combinator! : :descendant
  skip_ws
  complex = parse_complex_selector!
  skip_ws
  fail!("unexpected #{peek.inspect}") unless eof?
  SelectorAST::RelativeSelector.new(leading, complex)
end

#parse_relative_selector_argument_list(source) ⇒ Object



565
566
567
568
569
# File 'lib/dommy/internal/selector_parser.rb', line 565

def parse_relative_selector_argument_list(source)
  split_selector_source(source).map do |clause|
    Parser.new(clause, in_has: true, namespaces: @namespaces).parse_relative_selector!
  end
end

#parse_selector_argument_list(source, forgiving:) ⇒ Object



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'lib/dommy/internal/selector_parser.rb', line 549

def parse_selector_argument_list(source, forgiving:)
  clauses = split_selector_source(source)
  selectors = []
  clauses.each do |clause|
    begin
      selectors.concat(Parser.new(clause, in_has: @in_has, namespaces: @namespaces).parse_selector_list!.selectors)
    rescue InvalidSelector
      raise unless forgiving
    end
  end
  # A forgiving selector list (`:is`/`:where`) whose every clause is
  # invalid is still valid — it just matches nothing (empty list).
  fail!("empty selector list") if selectors.empty? && !forgiving
  SelectorAST::SelectorList.new(selectors)
end

#parse_selector_list!Object

selector-list := (',' )* with optional surrounding whitespace; an empty list or an empty element (leading/trailing/double comma) is invalid.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/dommy/internal/selector_parser.rb', line 157

def parse_selector_list!
  skip_ws
  fail!("empty selector") if eof?
  selectors = []
  selectors << record_clause { parse_complex_selector! }
  while peek == ","
    advance
    skip_ws
    fail!("empty selector in list") if eof? || peek == ","
    selectors << record_clause { parse_complex_selector! }
  end
  skip_ws
  fail!("unexpected #{peek.inspect}") unless eof?
  SelectorAST::SelectorList.new(selectors)
end

#parse_type_or_universal!Object

type := []? ( | '*')



292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/dommy/internal/selector_parser.rb', line 292

def parse_type_or_universal!
  # No prefix: the default namespace (if declared) applies to type and
  # universal selectors (but not attribute selectors).
  ns = namespace_prefix_ahead? ? parse_namespace_prefix! : @default_namespace
  if peek == "*"
    advance
    SelectorAST::UniversalSelector.new(ns)
  elsif ident_start?
    SelectorAST::TypeSelector.new(ns, consume_ident!)
  else
    fail!("expected type selector")
  end
end

#peek(offset = 0) ⇒ Object

---- cursor --------------------------------------------------------



826
# File 'lib/dommy/internal/selector_parser.rb', line 826

def peek(offset = 0) = @s[@i + offset]

#peek_wordObject



828
829
830
831
832
# File 'lib/dommy/internal/selector_parser.rb', line 828

def peek_word
  j = @i
  j += 1 while j < @n && @s[j].match?(/[A-Za-z]/)
  @s[@i...j]
end

#record_clauseObject

Capture a clause's source text + whether its subject is a pseudo-element.



174
175
176
177
178
179
# File 'lib/dommy/internal/selector_parser.rb', line 174

def record_clause
  start = @i
  complex = yield
  @clauses << {text: @s[start...@i].strip, pseudo_subject: complex.pseudo_element?}
  complex
end

#scan_ident_end(from) ⇒ Object

Index just past the identifier starting at from (no validation).



799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
# File 'lib/dommy/internal/selector_parser.rb', line 799

def scan_ident_end(from)
  j = from
  j += 1 if @s[j] == "-"
  while (ch = @s[j])
    if ch == "\\"
      j += 1
      if @s[j]&.match?(/[0-9A-Fa-f]/)
        count = 0
        while count < 6 && @s[j]&.match?(/[0-9A-Fa-f]/)
          j += 1
          count += 1
        end
        j += 1 if @s[j] && WS.include?(@s[j])
      else
        j += 1 if @s[j]
      end
    elsif ch.match?(/[A-Za-z0-9_\-]/) || ch.ord >= 0x80
      j += 1
    else
      break
    end
  end
  j
end

#skip_wsObject



836
837
838
839
840
841
842
843
# File 'lib/dommy/internal/selector_parser.rb', line 836

def skip_ws
  moved = false
  while !eof? && WS.include?(peek)
    advance
    moved = true
  end
  moved
end

#split_selector_source(source) ⇒ Object



581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/dommy/internal/selector_parser.rb', line 581

def split_selector_source(source)
  out = []
  current = +""
  depth = 0
  quote = nil
  source.each_char do |ch|
    if quote
      quote = nil if ch == quote
    elsif ch == '"' || ch == "'"
      quote = ch
    elsif ch == "(" || ch == "["
      depth += 1
    elsif ch == ")" || ch == "]"
      depth -= 1 if depth.positive?
    elsif ch == "," && depth.zero?
      out << current.strip
      current = +""
      next
    end
    current << ch
  end
  out << current.strip
  out.reject(&:empty?)
end

#type_start?Boolean

A compound may start with a type/universal selector when the next token is an ident, *, or a namespace prefix (*|, |, ident|).

Returns:

  • (Boolean)


282
283
284
285
286
287
288
289
# File 'lib/dommy/internal/selector_parser.rb', line 282

def type_start?
  c = peek
  return true if c == "*"
  return true if c == "|"
  return true if ident_start?

  false
end