Module: Dommy::Internal::SelectorMatcher
- Defined in:
- lib/dommy/internal/selector_matcher.rb
Constant Summary collapse
- HTML_NS =
"http://www.w3.org/1999/xhtml"- SVG_NS =
"http://www.w3.org/2000/svg"- INDEXABLE_ANCESTOR_KINDS =
The descendant combinator walks EVERY ancestor of
current. Wrapping each one into a Dommy element (to run #matches_compound?) was the dominant cost on a jQuery-heavy page. So gate each ancestor by the left compound's static prefilter on the BACKEND node first — a superset, so #matches_compound? is still authoritative — and only wrap the ancestors that can possibly match. Prefilters the index can answer an ancestor existence query for. %i[id class type].freeze
- ELEMENT_NODE =
1
Class Method Summary collapse
-
.anchor_relation?(leftmost, anchor, combinator) ⇒ Boolean
Does
leftmoststand incombinatorrelation to the :has() anchor? (The implied :scope at the head of a relative selector.). - .ascii_ws?(char) ⇒ Boolean
-
.backend_passes?(bnode, prefilter) ⇒ Boolean
Does the backend node satisfy a pre-filter? A SUPERSET test (presence / exact id / class token / tag) — never a false negative, so #matches? can prune.
-
.backend_root_of(root) ⇒ Object
The backend (lexbor) node whose subtree holds the candidates.
- .child_elements(root) ⇒ Object
-
.class_attr_token?(raw, token) ⇒ Boolean
Is
tokena whitespace-separated word of the raw class attribute? Scans in place (no split/allocation), since this runs for every node in the tree. - .closest(element, selector_ast) ⇒ Object
-
.constraint_invalid?(element) ⇒ Boolean
:invalid/:validapply to candidates (by their validity) and to a form / fieldset (by whether any descendant candidate is invalid). - .constraint_valid?(element) ⇒ Boolean
- .contains_element?(ancestor, element) ⇒ Boolean
- .default_scope(root) ⇒ Object
- .descendant_candidates(element) ⇒ Object
-
.dir_match?(element, argument) ⇒ Boolean
:dir()from the nearest dir attribute (ltr/rtl; auto and absent fall back to the document default ltr — no computed-direction or content heuristics). - .disabled_element?(element) ⇒ Boolean
-
.document_of(root) ⇒ Object
The owning Document (for identity-stable #wrap_node of a backend match).
-
.each_backend_descendant(bnode, &block) ⇒ Object
Every descendant ELEMENT of the backend node
bnode, in document order, excludingbnodeitself — walking lexbor nodes directly via the first-child / next-sibling chain (no Dommy wrap and, unlikeelement_children, no per-node NodeSet allocation, which dominated GC). -
.each_descendant(root, &block) ⇒ Object
Yield every descendant element of
rootin document (pre-order) order, without materializing the full list — so a first-match walk (#query_first) can stop early. - .editable_via_contenteditable?(element) ⇒ Boolean
- .element_descendants(root) ⇒ Object
- .element_node?(node) ⇒ Boolean
- .element_siblings(element) ⇒ Object
- .enableable_element?(element) ⇒ Boolean
-
.exact_class_or_id_prefilter(compound) ⇒ Object
[:class|:id, value] when
compoundis EXACTLY one class or id selector (no type, no pseudo, nothing else), else nil. -
.fast_query(root, selector_ast, scope:, first: false) ⇒ Object
Backend pre-filter fast path.
- .fieldset_disabled?(element) ⇒ Boolean
- .first_legend_child(fieldset) ⇒ Object
- .form_control_optional?(element) ⇒ Boolean
- .form_control_required?(element) ⇒ Boolean
-
.has_relative?(element, relative_selectors, scope:) ⇒ Boolean
:has(RS)— the relative selector is anchored atelement(the implied :scope). -
.html_document?(element) ⇒ Boolean
Case-insensitive matching applies in an HTML document (text/html).
- .html_element?(element) ⇒ Boolean
-
.lang_match?(element, ranges) ⇒ Boolean
:lang()accepts a list of language ranges; the element's content language (nearest lang attribute) must extended-filter-match any of them (RFC 4647 §3.3.2 — sode-DEmatchesde-Latn-DE). - .lang_range_match?(actual, range) ⇒ Boolean
- .link_element?(element) ⇒ Boolean
- .match_descendant_left(current, compound, parts, index, scope:, anchor:, leading:) ⇒ Object
- .match_left_from(current, parts, index, scope:, anchor:, leading:) ⇒ Object
- .matches?(element, selector_ast, scope: nil) ⇒ Boolean
- .matches_attribute?(element, selector) ⇒ Boolean
-
.matches_complex?(element, complex, scope:, anchor: nil, leading: nil) ⇒ Boolean
Match a complex selector with the rightmost compound as subject, evaluating right-to-left WITH backtracking: descendant and
~combinators have multiple candidates, and a failure further left must retry the next candidate (.a > .b .cwhere the nearest.bancestor has the wrong parent). - .matches_compound?(element, compound, scope:) ⇒ Boolean
- .matches_pseudo_class?(element, pseudo, scope:) ⇒ Boolean
- .matches_simple?(element, selector, scope:) ⇒ Boolean
- .matches_type?(element, type) ⇒ Boolean
-
.matches_type_namespace?(element, namespace) ⇒ Boolean
Namespace values the parser produces: nil (no prefix and no default namespace — matches any namespace), :any (
*|), :none (|— only the null namespace), or a URI String (a resolvedprefix|or the default namespace from @namespace) — the element must be in that namespace. -
.mutable_input_type?(element) ⇒ Boolean
Text-like input types that can be read-write (not button/checkbox/etc.).
- .next_of_type(element) ⇒ Object
- .nth_child?(element, nth, reverse, scope:) ⇒ Boolean
- .nth_match?(index, a, b) ⇒ Boolean
- .nth_of_type?(element, nth, reverse) ⇒ Boolean
-
.prefilter_for(compound) ⇒ Object
The most selective static check in
compound(id > class > attribute > type); nil if it has none (universal/pseudo-only subject). - .previous_of_type(element) ⇒ Object
-
.query(root, selector_ast, scope: nil) ⇒ Object
querySelectorAll.
-
.query_first(root, selector_ast, scope: nil) ⇒ Object
querySelector — the first element in document order that matches, or nil.
- .read_only_element?(element) ⇒ Boolean
-
.read_write_element?(element) ⇒ Boolean
:read-writematches an editable control (a mutable text input / textarea, or an element with contenteditable);:read-onlyis its complement over the elements the pseudo-classes apply to. -
.relative_candidates(element, combinator) ⇒ Object
The subject search space per leading combinator: descendants for descendant/child relations; the following sibling(s) and their descendants for sibling relations (
:has(+ .a .b)'s subject lives inside the next sibling). -
.requirable_element?(element) ⇒ Boolean
:required/:optionalapply to input / select / textarea per therequiredattribute. - .same_type?(a, b) ⇒ Boolean
-
.static_prefilters(selector_ast) ⇒ Object
One [kind, value] pre-filter per complex selector — taken from its subject (rightmost) compound.
- .text_node_content?(node) ⇒ Boolean
-
.validation_candidate?(element) ⇒ Boolean
A candidate for constraint validation: a form-associated control whose
willValidateis true (not disabled / readonly / barred).
Class Method Details
.anchor_relation?(leftmost, anchor, combinator) ⇒ Boolean
Does leftmost stand in combinator relation to the :has()
anchor? (The implied :scope at the head of a relative selector.)
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/dommy/internal/selector_matcher.rb', line 237 def anchor_relation?(leftmost, anchor, combinator) case combinator when :child anchor.equal?(leftmost.parent_element) when :next_sibling anchor.equal?(leftmost.previous_element_sibling) when :subsequent_sibling sib = leftmost.previous_element_sibling while sib return true if anchor.equal?(sib) sib = sib.previous_element_sibling end false else # :descendant !anchor.equal?(leftmost) && anchor.respond_to?(:contains?) && anchor.contains?(leftmost) end end |
.ascii_ws?(char) ⇒ Boolean
605 606 607 |
# File 'lib/dommy/internal/selector_matcher.rb', line 605 def ascii_ws?(char) char == " " || char == "\t" || char == "\n" || char == "\f" || char == "\r" end |
.backend_passes?(bnode, prefilter) ⇒ Boolean
Does the backend node satisfy a pre-filter? A SUPERSET test (presence / exact id / class token / tag) — never a false negative, so #matches? can prune.
577 578 579 580 581 582 583 584 585 |
# File 'lib/dommy/internal/selector_matcher.rb', line 577 def backend_passes?(bnode, prefilter) kind, value = prefilter case kind when :id then bnode["id"] == value when :class then class_attr_token?(bnode["class"], value) when :attr then !bnode[value].nil? when :type then (name = bnode.name) && name.casecmp?(value) end end |
.backend_root_of(root) ⇒ Object
The backend (lexbor) node whose subtree holds the candidates.
610 611 612 613 614 615 616 |
# File 'lib/dommy/internal/selector_matcher.rb', line 610 def backend_root_of(root) if root.is_a?(Document) root.backend_doc elsif root.respond_to?(:__dommy_backend_node__) root.__dommy_backend_node__ end end |
.child_elements(root) ⇒ Object
491 492 493 494 495 496 497 498 499 |
# File 'lib/dommy/internal/selector_matcher.rb', line 491 def child_elements(root) if root.is_a?(Document) root.children.to_a elsif root.respond_to?(:children) root.children.to_a else [] end end |
.class_attr_token?(raw, token) ⇒ Boolean
Is token a whitespace-separated word of the raw class attribute? Scans in
place (no split/allocation), since this runs for every node in the tree.
589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 |
# File 'lib/dommy/internal/selector_matcher.rb', line 589 def class_attr_token?(raw, token) return false if raw.nil? || raw.empty? pos = 0 len = token.length while (i = raw.index(token, pos)) before = i.zero? || ascii_ws?(raw[i - 1]) after_index = i + len after = after_index >= raw.length || ascii_ws?(raw[after_index]) return true if before && after pos = i + 1 end false end |
.closest(element, selector_ast) ⇒ Object
120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/dommy/internal/selector_matcher.rb', line 120 def closest(element, selector_ast) node = element while node&.respond_to?(:matches?) # DOM Standard: closest keeps the *original* element as the # scoping root for every iteration. return node if matches?(node, selector_ast, scope: element) node = node.parent_element end nil end |
.constraint_invalid?(element) ⇒ Boolean
:invalid / :valid apply to candidates (by their validity) and to a
form / fieldset (by whether any descendant candidate is invalid).
659 660 661 662 663 664 665 666 |
# File 'lib/dommy/internal/selector_matcher.rb', line 659 def constraint_invalid?(element) name = element.local_name.to_s.downcase if %w[form fieldset].include?(name) descendant_candidates(element).any? { |c| !c.validity.valid } else validation_candidate?(element) && !element.validity.valid end end |
.constraint_valid?(element) ⇒ Boolean
668 669 670 671 672 673 674 675 |
# File 'lib/dommy/internal/selector_matcher.rb', line 668 def constraint_valid?(element) name = element.local_name.to_s.downcase if %w[form fieldset].include?(name) descendant_candidates(element).all? { |c| c.validity.valid } else validation_candidate?(element) && element.validity.valid end end |
.contains_element?(ancestor, element) ⇒ Boolean
763 764 765 766 767 768 769 770 771 |
# File 'lib/dommy/internal/selector_matcher.rb', line 763 def contains_element?(ancestor, element) node = element while node return true if node.equal?(ancestor) node = node.parent_element end false end |
.default_scope(root) ⇒ Object
470 471 472 |
# File 'lib/dommy/internal/selector_matcher.rb', line 470 def default_scope(root) root if root.respond_to?(:__dommy_backend_node__) && !root.is_a?(Document) end |
.descendant_candidates(element) ⇒ Object
677 678 679 680 681 |
# File 'lib/dommy/internal/selector_matcher.rb', line 677 def descendant_candidates(element) element.query_selector_all("input, select, textarea, button").select do |c| validation_candidate?(c) end end |
.dir_match?(element, argument) ⇒ Boolean
:dir() from the nearest dir attribute (ltr/rtl; auto and absent
fall back to the document default ltr — no computed-direction or
content heuristics).
827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 |
# File 'lib/dommy/internal/selector_matcher.rb', line 827 def dir_match?(element, argument) expected = Array(argument).first.to_s.downcase return false unless %w[ltr rtl].include?(expected) actual = "ltr" node = element while node value = node.get_attribute("dir").to_s.downcase if node.respond_to?(:get_attribute) if %w[ltr rtl].include?(value) actual = value break end node = node.parent_element end actual == expected end |
.disabled_element?(element) ⇒ Boolean
734 735 736 737 738 739 740 741 742 |
# File 'lib/dommy/internal/selector_matcher.rb', line 734 def disabled_element?(element) return true if element.has_attribute?("disabled") if element.local_name.to_s.downcase == "option" parent = element.parent_element return true if parent&.local_name.to_s.downcase == "optgroup" && parent.has_attribute?("disabled") end fieldset_disabled?(element) end |
.document_of(root) ⇒ Object
The owning Document (for identity-stable #wrap_node of a backend match).
619 620 621 622 623 |
# File 'lib/dommy/internal/selector_matcher.rb', line 619 def document_of(root) return root if root.is_a?(Document) root.document if root.respond_to?(:document) end |
.each_backend_descendant(bnode, &block) ⇒ Object
Every descendant ELEMENT of the backend node bnode, in document order,
excluding bnode itself — walking lexbor nodes directly via the
first-child / next-sibling chain (no Dommy wrap and, unlike
element_children, no per-node NodeSet allocation, which dominated GC).
507 508 509 510 511 512 513 514 515 516 517 |
# File 'lib/dommy/internal/selector_matcher.rb', line 507 def each_backend_descendant(bnode, &block) child = bnode.first_element_child while child block.call(child) each_backend_descendant(child, &block) # `next_element` is the backend's native (C) element-only sibling step; # it skips intervening text/comment nodes in one call, where a Ruby # `.next`-until-element loop cost ~6% of a heavy page's wall time. child = child.next_element end end |
.each_descendant(root, &block) ⇒ Object
Yield every descendant element of root in document (pre-order) order,
without materializing the full list — so a first-match walk (#query_first)
can stop early. #element_descendants collects them when the whole set is
needed (#query / querySelectorAll).
484 485 486 487 488 489 |
# File 'lib/dommy/internal/selector_matcher.rb', line 484 def each_descendant(root, &block) child_elements(root).each do |child| block.call(child) each_descendant(child, &block) end end |
.editable_via_contenteditable?(element) ⇒ Boolean
729 730 731 732 |
# File 'lib/dommy/internal/selector_matcher.rb', line 729 def editable_via_contenteditable?(element) v = element.get_attribute("contenteditable") !v.nil? && v.to_s.downcase != "false" end |
.element_descendants(root) ⇒ Object
474 475 476 477 478 |
# File 'lib/dommy/internal/selector_matcher.rb', line 474 def element_descendants(root) out = [] each_descendant(root) { |element| out << element } out end |
.element_node?(node) ⇒ Boolean
625 626 627 |
# File 'lib/dommy/internal/selector_matcher.rb', line 625 def element_node?(node) node.respond_to?(:tag_name) end |
.element_siblings(element) ⇒ Object
441 442 443 444 |
# File 'lib/dommy/internal/selector_matcher.rb', line 441 def element_siblings(element) parent = element.parent_element parent ? parent.children.to_a : [element] end |
.enableable_element?(element) ⇒ Boolean
647 648 649 |
# File 'lib/dommy/internal/selector_matcher.rb', line 647 def enableable_element?(element) %w[button input select textarea optgroup option fieldset].include?(element.local_name.to_s.downcase) end |
.exact_class_or_id_prefilter(compound) ⇒ Object
[:class|:id, value] when compound is EXACTLY one class or id selector
(no type, no pseudo, nothing else), else nil. For such a compound the index
lookup is an exact match — not just a superset — so an index "does an
ancestor match?" answer can be trusted without re-running matches_compound?.
563 564 565 566 567 568 569 570 571 572 573 |
# File 'lib/dommy/internal/selector_matcher.rb', line 563 def exact_class_or_id_prefilter(compound) return nil unless compound.type.nil? && compound.pseudo_element.nil? subs = compound.subclass_selectors return nil unless subs.size == 1 case subs.first when SelectorAST::ClassSelector then [:class, subs.first.value] when SelectorAST::IdSelector then [:id, subs.first.value] end end |
.fast_query(root, selector_ast, scope:, first: false) ⇒ Object
Backend pre-filter fast path. The Ruby matcher wraps EVERY descendant into
a Dommy element before matching — so a .foo query over a 5000-element
tree wraps all 5000 to return the 50 matches. Instead, walk the backend
(lexbor) nodes directly and gate each by a cheap static pre-filter taken
from the subject (rightmost) compound — its id, class, or a required
attribute, read straight off the backend node — and only wrap + run the
full #matches? on the candidates that pass. The pre-filter is a SUPERSET of
the subject's requirement (no false negatives), so #matches? (the authority,
which still handles combinators, pseudo-classes and types) yields exactly
the same set, in the same document order. Returns the matches (possibly
empty), or nil when the selector has no static subject pre-filter (a
universal/pseudo-only subject) — then the caller uses the Ruby matcher.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/dommy/internal/selector_matcher.rb', line 67 def fast_query(root, selector_ast, scope:, first: false) prefilters = static_prefilters(selector_ast) return nil unless prefilters backend_root = backend_root_of(root) doc = document_of(root) return nil unless backend_root && doc # The overwhelmingly common case is one selector (one pre-filter); skip the # Array#any? block dispatch on every node for it. single = prefilters.size == 1 ? prefilters.first : nil # Index fast path: a single id/class/tag pre-filter looks its candidates up # directly (O(matches)) instead of walking the whole (sub)tree per call — # the dominant cost on a jQuery-heavy page (`$.find` → querySelectorAll). # Works for an element scope too (jQuery `.find` is element-scoped): the # index restricts candidates to the scope's pre-order interval. Falls # through to the walk for an :attr pre-filter / multi-selector list / a # scope the index doesn't know (candidates == nil). if single && (index = doc.__internal_selector_index__) scope_node = root.equal?(doc) ? nil : backend_root candidates = index.candidates(single, scope_node) if candidates out = [] catch(:done) do candidates.each do |bnode| element = doc.wrap_node(bnode) next unless element && matches?(element, selector_ast, scope: scope) out << element throw(:done) if first end end return out end end out = [] catch(:done) do each_backend_descendant(backend_root) do |bnode| hit = single ? backend_passes?(bnode, single) : prefilters.any? { |pf| backend_passes?(bnode, pf) } next unless hit element = doc.wrap_node(bnode) next unless element && matches?(element, selector_ast, scope: scope) out << element throw(:done) if first end end out end |
.fieldset_disabled?(element) ⇒ Boolean
744 745 746 747 748 749 750 751 752 753 754 755 756 757 |
# File 'lib/dommy/internal/selector_matcher.rb', line 744 def fieldset_disabled?(element) parent = element.parent_element while parent if parent.local_name.to_s.downcase == "fieldset" && parent.has_attribute?("disabled") legend = first_legend_child(parent) return false if legend && contains_element?(legend, element) return true end parent = parent.parent_element end false end |
.first_legend_child(fieldset) ⇒ Object
759 760 761 |
# File 'lib/dommy/internal/selector_matcher.rb', line 759 def first_legend_child(fieldset) fieldset.children.to_a.find { |child| child.local_name.to_s.downcase == "legend" } end |
.form_control_optional?(element) ⇒ Boolean
693 694 695 |
# File 'lib/dommy/internal/selector_matcher.rb', line 693 def form_control_optional?(element) requirable_element?(element) && !element.has_attribute?("required") end |
.form_control_required?(element) ⇒ Boolean
689 690 691 |
# File 'lib/dommy/internal/selector_matcher.rb', line 689 def form_control_required?(element) requirable_element?(element) && element.has_attribute?("required") end |
.has_relative?(element, relative_selectors, scope:) ⇒ Boolean
:has(RS) — the relative selector is anchored at element (the
implied :scope). Candidates are potential subjects (the relative
complex's rightmost compound); the anchor relation of the chain's
leftmost element is enforced inside matches_complex? via anchor:/
leading:, so e.g. section:has(.a .b) cannot satisfy .a with an
ancestor outside the section, and :has(+ .a .b) finds subjects
inside the adjacent sibling. Inside :has, :scope is the anchor.
385 386 387 388 389 390 391 392 |
# File 'lib/dommy/internal/selector_matcher.rb', line 385 def has_relative?(element, relative_selectors, scope:) relative_selectors.any? do |relative| leading = relative.leading_combinator || :descendant relative_candidates(element, leading).any? do |candidate| matches_complex?(candidate, relative.complex, scope: element, anchor: element, leading: leading) end end end |
.html_document?(element) ⇒ Boolean
Case-insensitive matching applies in an HTML document (text/html). Delegate
to the document's own cheap flag (@content_type == "text/html") instead
of re-deriving it with content_type.downcase.include?("html") on every
element — that string work showed up across millions of match calls. (It
also fixes XHTML, which is genuinely case-sensitive.)
642 643 644 645 |
# File 'lib/dommy/internal/selector_matcher.rb', line 642 def html_document?(element) doc = element.owner_document !doc.nil? && doc.html_document? end |
.html_element?(element) ⇒ Boolean
633 634 635 |
# File 'lib/dommy/internal/selector_matcher.rb', line 633 def html_element?(element) element.namespace_uri.nil? || element.namespace_uri == HTML_NS end |
.lang_match?(element, ranges) ⇒ Boolean
:lang() accepts a list of language ranges; the element's content
language (nearest lang attribute) must extended-filter-match any of
them (RFC 4647 §3.3.2 — so de-DE matches de-Latn-DE).
776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 |
# File 'lib/dommy/internal/selector_matcher.rb', line 776 def lang_match?(element, ranges) actual = nil node = element while node value = node.get_attribute("lang") if node.respond_to?(:get_attribute) if value && !value.to_s.empty? actual = value.to_s.downcase break end node = node.parent_element end return false unless actual Array(ranges).any? { |range| lang_range_match?(actual, range.to_s.downcase) } end |
.lang_range_match?(actual, range) ⇒ Boolean
792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 |
# File 'lib/dommy/internal/selector_matcher.rb', line 792 def lang_range_match?(actual, range) return false if range.empty? return true if range == "*" = actual.split("-") subs = range.split("-") return false unless subs[0] == "*" || [0] == subs[0] i = 1 j = 1 while j < subs.length if subs[j] == "*" j += 1 elsif i >= .length return false elsif [i] == subs[j] i += 1 j += 1 elsif [i].length == 1 # A singleton subtag (e.g. "x") ends the matchable prefix. return false else i += 1 end end true end |
.link_element?(element) ⇒ Boolean
820 821 822 |
# File 'lib/dommy/internal/selector_matcher.rb', line 820 def link_element?(element) %w[a area link].include?(element.local_name.to_s.downcase) && element.has_attribute?("href") end |
.match_descendant_left(current, compound, parts, index, scope:, anchor:, leading:) ⇒ Object
199 200 201 202 203 204 205 206 207 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_matcher.rb', line 199 def match_descendant_left(current, compound, parts, index, scope:, anchor:, leading:) doc = current.owner_document prefilter = prefilter_for(compound) # nil ⇒ no static gate, must wrap every ancestor # Ask the index about `current`'s ancestors before walking them. For an # indexable compound this is O(log): # - no ancestor even passes the (necessary) prefilter ⇒ the whole branch # fails, skip the walk entirely (the big win: candidates with no such # ancestor used to walk to the root for nothing); # - additionally, when this is the chain's LEFTMOST compound (index == 1, # no :has anchor) and it is EXACTLY a class/id (so the index match is # not just a superset), any matching ancestor completes the chain. if doc && prefilter && INDEXABLE_ANCESTOR_KINDS.include?(prefilter[0]) && (sel_index = doc.__internal_selector_index__) && (enter = sel_index.enter_of(current.__dommy_backend_node__)) return false unless sel_index.any_ancestor?(prefilter, enter) return true if index == 1 && anchor.nil? && exact_class_or_id_prefilter(compound) end backend = current.__dommy_backend_node__ backend = backend && backend.parent while backend && doc if backend.node_type == ELEMENT_NODE && (prefilter.nil? || backend_passes?(backend, prefilter)) parent = doc.wrap_node(backend) if parent && matches_compound?(parent, compound, scope: scope) && match_left_from(parent, parts, index - 1, scope: scope, anchor: anchor, leading: leading) return true end end backend = backend.parent end false end |
.match_left_from(current, parts, index, scope:, anchor:, leading:) ⇒ Object
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/dommy/internal/selector_matcher.rb', line 152 def match_left_from(current, parts, index, scope:, anchor:, leading:) if index.zero? return true if anchor.nil? return anchor_relation?(current, anchor, leading || :descendant) end combinator = parts[index].combinator || :descendant compound = parts[index - 1].compound case combinator when :child parent = current.parent_element !parent.nil? && matches_compound?(parent, compound, scope: scope) && match_left_from(parent, parts, index - 1, scope: scope, anchor: anchor, leading: leading) when :next_sibling sib = current.previous_element_sibling !sib.nil? && matches_compound?(sib, compound, scope: scope) && match_left_from(sib, parts, index - 1, scope: scope, anchor: anchor, leading: leading) when :subsequent_sibling sib = current.previous_element_sibling while sib if matches_compound?(sib, compound, scope: scope) && match_left_from(sib, parts, index - 1, scope: scope, anchor: anchor, leading: leading) return true end sib = sib.previous_element_sibling end false when :column # `||` needs table column semantics Dommy doesn't model; the # design memo keeps it unsupported — match nothing (never treat # it as a descendant combinator). false else # :descendant match_descendant_left(current, compound, parts, index, scope: scope, anchor: anchor, leading: leading) end end |
.matches?(element, selector_ast, scope: nil) ⇒ Boolean
13 14 15 16 17 |
# File 'lib/dommy/internal/selector_matcher.rb', line 13 def matches?(element, selector_ast, scope: nil) return false unless element&.respond_to?(:__dommy_backend_node__) selector_ast.selectors.any? { |complex| matches_complex?(element, complex, scope: scope) } end |
.matches_attribute?(element, selector) ⇒ Boolean
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/dommy/internal/selector_matcher.rb', line 309 def matches_attribute?(element, selector) name = selector.name.to_s actual = element.get_attribute(name) return false if actual.nil? return true unless selector.matcher actual = actual.to_s expected = selector.value.to_s if selector.case_flag.to_s.downcase == "i" actual = actual.downcase expected = expected.downcase end case selector.matcher when "=" then actual == expected when "~=" then actual.split(/\s+/).include?(expected) when "|=" then actual == expected || actual.start_with?("#{expected}-") # `^=`/`$=`/`*=` against the empty string never match (Selectors 4 §6.2). when "^=" then !expected.empty? && actual.start_with?(expected) when "$=" then !expected.empty? && actual.end_with?(expected) when "*=" then !expected.empty? && actual.include?(expected) else false end end |
.matches_complex?(element, complex, scope:, anchor: nil, leading: nil) ⇒ Boolean
Match a complex selector with the rightmost compound as subject,
evaluating right-to-left WITH backtracking: descendant and ~
combinators have multiple candidates, and a failure further left
must retry the next candidate (.a > .b .c where the nearest
.b ancestor has the wrong parent).
anchor:/leading: carry :has() semantics — when the chain is
fully consumed, its leftmost element must additionally relate to
the anchor via the relative selector's leading combinator.
141 142 143 144 145 146 |
# File 'lib/dommy/internal/selector_matcher.rb', line 141 def matches_complex?(element, complex, scope:, anchor: nil, leading: nil) parts = complex.parts return false unless matches_compound?(element, parts.last.compound, scope: scope) match_left_from(element, parts, parts.length - 1, scope: scope, anchor: anchor, leading: leading) end |
.matches_compound?(element, compound, scope:) ⇒ Boolean
256 257 258 259 260 261 262 263 264 |
# File 'lib/dommy/internal/selector_matcher.rb', line 256 def matches_compound?(element, compound, scope:) # A pseudo-element subject never matches an element (querySelector*, # matches). The cascade strips pseudo-elements before matching and # indexes those rules separately. return false if compound.pseudo_element return false unless matches_type?(element, compound.type) compound.subclass_selectors.all? { |selector| matches_simple?(element, selector, scope: scope) } end |
.matches_pseudo_class?(element, pseudo, scope:) ⇒ Boolean
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/dommy/internal/selector_matcher.rb', line 333 def matches_pseudo_class?(element, pseudo, scope:) case pseudo.name when "scope" then scope ? element.equal?(scope) : false when "root" then element.owner_document&.document_element.equal?(element) when "empty" then element.child_nodes.none? { |node| element_node?(node) || text_node_content?(node) } when "first-child" then element.previous_element_sibling.nil? when "last-child" then element.next_element_sibling.nil? when "only-child" then element.previous_element_sibling.nil? && element.next_element_sibling.nil? when "first-of-type" then previous_of_type(element).nil? when "last-of-type" then next_of_type(element).nil? when "only-of-type" then previous_of_type(element).nil? && next_of_type(element).nil? when "nth-child" then nth_child?(element, pseudo.argument, false, scope: scope) when "nth-last-child" then nth_child?(element, pseudo.argument, true, scope: scope) when "nth-of-type" then nth_of_type?(element, pseudo.argument, false) when "nth-last-of-type" then nth_of_type?(element, pseudo.argument, true) when "is", "where" then matches?(element, pseudo.argument, scope: scope) when "not" then !matches?(element, pseudo.argument, scope: scope) when "has" then has_relative?(element, pseudo.argument, scope: scope) when "checked" then Internal.checked_state?(element) when "enabled" then enableable_element?(element) && !disabled_element?(element) when "disabled" then enableable_element?(element) && disabled_element?(element) when "focus", "focus-visible" then element.owner_document&.__internal_focused_element__.equal?(element) when "focus-within" focused = element.owner_document&.__internal_focused_element__ focused && (element.equal?(focused) || element.contains?(focused)) when "hover" hovered = element.owner_document&.__internal_hovered_element__ hovered && (element.equal?(hovered) || element.contains?(hovered)) when "invalid" then constraint_invalid?(element) when "valid" then constraint_valid?(element) when "required" then form_control_required?(element) when "optional" then form_control_optional?(element) when "read-only" then read_only_element?(element) when "read-write" then read_write_element?(element) when "active", "visited" then false # supported-but-currently-false (no pointer state / history) when "dir" then dir_match?(element, pseudo.argument) when "target" then element.get_attribute("id").to_s == Internal.target_id(element.owner_document).to_s && !Internal.target_id(element.owner_document).nil? when "lang" then lang_match?(element, pseudo.argument) when "link" then link_element?(element) when "any-link" then link_element?(element) else false end end |
.matches_simple?(element, selector, scope:) ⇒ Boolean
294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/dommy/internal/selector_matcher.rb', line 294 def matches_simple?(element, selector, scope:) case selector when SelectorAST::IdSelector element.get_attribute("id").to_s == selector.value when SelectorAST::ClassSelector element.class_list.include?(selector.value) when SelectorAST::AttributeSelector matches_attribute?(element, selector) when SelectorAST::PseudoClass matches_pseudo_class?(element, selector, scope: scope) else false end end |
.matches_type?(element, type) ⇒ Boolean
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/dommy/internal/selector_matcher.rb', line 266 def matches_type?(element, type) return true unless type return matches_type_namespace?(element, type.namespace) if type.is_a?(SelectorAST::UniversalSelector) return false unless matches_type_namespace?(element, type.namespace) actual = element.local_name.to_s expected = type.name.to_s # Type selectors are ASCII case-insensitive in an HTML document, case- # sensitive otherwise. `casecmp?` does that comparison WITHOUT allocating # two downcased copies per call — and this runs once per element per # compound, so on a big page (jQuery `.find()` hammering querySelectorAll) # the old `downcase == downcase` was a top allocator (String#downcase + # GC). `==` short-circuits the common exact-match case first. actual == expected || (html_document?(element) && actual.casecmp?(expected)) end |
.matches_type_namespace?(element, namespace) ⇒ Boolean
Namespace values the parser produces: nil (no prefix and no default
namespace — matches any namespace), :any (*|), :none (| — only the
null namespace), or a URI String (a resolved prefix| or the default
namespace from @namespace) — the element must be in that namespace.
287 288 289 290 291 292 |
# File 'lib/dommy/internal/selector_matcher.rb', line 287 def matches_type_namespace?(element, namespace) return true if namespace.nil? || namespace == :any return element.namespace_uri.to_s.empty? if namespace == :none element.namespace_uri.to_s == namespace.to_s end |
.mutable_input_type?(element) ⇒ Boolean
Text-like input types that can be read-write (not button/checkbox/etc.).
722 723 724 725 726 727 |
# File 'lib/dommy/internal/selector_matcher.rb', line 722 def mutable_input_type?(element) %w[text search url tel email password date month week time datetime-local number range color].include?( (element.get_attribute("type") || "text").to_s.downcase ) end |
.next_of_type(element) ⇒ Object
456 457 458 459 460 461 462 463 464 |
# File 'lib/dommy/internal/selector_matcher.rb', line 456 def next_of_type(element) sib = element.next_element_sibling while sib return sib if same_type?(sib, element) sib = sib.next_element_sibling end nil end |
.nth_child?(element, nth, reverse, scope:) ⇒ Boolean
417 418 419 420 421 422 423 424 425 |
# File 'lib/dommy/internal/selector_matcher.rb', line 417 def nth_child?(element, nth, reverse, scope:) siblings = element_siblings(element) siblings = siblings.reverse if reverse if nth.of_selector_list siblings = siblings.select { |candidate| matches?(candidate, nth.of_selector_list, scope: scope) } end index = siblings.index(element) index && nth_match?(index + 1, nth.a, nth.b) end |
.nth_match?(index, a, b) ⇒ Boolean
434 435 436 437 438 439 |
# File 'lib/dommy/internal/selector_matcher.rb', line 434 def nth_match?(index, a, b) return index == b if a.zero? n = index - b (n % a).zero? && (n / a) >= 0 end |
.nth_of_type?(element, nth, reverse) ⇒ Boolean
427 428 429 430 431 432 |
# File 'lib/dommy/internal/selector_matcher.rb', line 427 def nth_of_type?(element, nth, reverse) siblings = element_siblings(element).select { |candidate| same_type?(candidate, element) } siblings = siblings.reverse if reverse index = siblings.index(element) index && nth_match?(index + 1, nth.a, nth.b) end |
.prefilter_for(compound) ⇒ Object
The most selective static check in compound (id > class > attribute >
type); nil if it has none (universal/pseudo-only subject). The exact
case/namespace and pseudo state are still left to the authoritative
#matches? — the prefilter only has to be a SUPERSET.
536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 |
# File 'lib/dommy/internal/selector_matcher.rb', line 536 def prefilter_for(compound) id = klass = attr = nil compound.subclass_selectors.each do |sub| case sub when SelectorAST::IdSelector then id ||= sub.value when SelectorAST::ClassSelector then klass ||= sub.value when SelectorAST::AttributeSelector then attr ||= sub.name if sub.namespace.nil? end end return [:id, id] if id return [:class, klass] if klass return [:attr, attr] if attr # A concrete tag (`a`, `div span`) gates the backend walk by tag name — # without it a type-only subject wraps EVERY element before matching, # which dominated a jQuery-heavy page (`$.find('div a')`). Case/namespace # exactness is matches?'s job, so this is a (case-insensitive) superset. type = compound.type return [:type, type.name.to_s] if type.is_a?(SelectorAST::TypeSelector) && !type.name.to_s.empty? nil end |
.previous_of_type(element) ⇒ Object
446 447 448 449 450 451 452 453 454 |
# File 'lib/dommy/internal/selector_matcher.rb', line 446 def previous_of_type(element) sib = element.previous_element_sibling while sib return sib if same_type?(sib, element) sib = sib.previous_element_sibling end nil end |
.query(root, selector_ast, scope: nil) ⇒ Object
querySelectorAll. The candidate set is exactly root's descendants:
querySelector(All) results are always descendants of the context node, so
we walk only its subtree. (An element scope used to walk up to the document
and back down, filtering every element by root.contains? — O(whole tree)
instead of O(subtree).) scope is still threaded into #matches? so a
:scope-relative selector resolves against the context; the matcher climbs
to ancestors above root itself when a left-hand combinator needs them.
26 27 28 29 30 31 32 33 34 |
# File 'lib/dommy/internal/selector_matcher.rb', line 26 def query(root, selector_ast, scope: nil) scope ||= default_scope(root) fast = fast_query(root, selector_ast, scope: scope) return fast if fast element_descendants(root).select do |element| matches?(element, selector_ast, scope: scope) end end |
.query_first(root, selector_ast, scope: nil) ⇒ Object
querySelector — the first element in document order that matches, or nil. Same candidate set and scoping as #query, but it stops at the first match rather than collecting every match and taking .first. A querySelector-heavy SPA spends much of its time here, and most queries either match early or are single-purpose, so short-circuiting the walk avoids touching (and matching against) the rest of the tree.
42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/dommy/internal/selector_matcher.rb', line 42 def query_first(root, selector_ast, scope: nil) scope ||= default_scope(root) fast = fast_query(root, selector_ast, scope: scope, first: true) return fast.first if fast catch(:found) do each_descendant(root) do |element| throw(:found, element) if matches?(element, selector_ast, scope: scope) end nil end end |
.read_only_element?(element) ⇒ Boolean
713 714 715 716 717 718 719 |
# File 'lib/dommy/internal/selector_matcher.rb', line 713 def read_only_element?(element) name = element.local_name.to_s.downcase return !read_write_element?(element) if %w[input textarea].include?(name) # For other elements, :read-only matches when not editable. !editable_via_contenteditable?(element) end |
.read_write_element?(element) ⇒ Boolean
:read-write matches an editable control (a mutable text input / textarea,
or an element with contenteditable); :read-only is its complement over
the elements the pseudo-classes apply to.
700 701 702 703 704 705 706 707 708 709 710 711 |
# File 'lib/dommy/internal/selector_matcher.rb', line 700 def read_write_element?(element) name = element.local_name.to_s.downcase if name == "textarea" return !element.has_attribute?("readonly") && !disabled_element?(element) end if name == "input" return false unless mutable_input_type?(element) return !element.has_attribute?("readonly") && !disabled_element?(element) end editable_via_contenteditable?(element) end |
.relative_candidates(element, combinator) ⇒ Object
The subject search space per leading combinator: descendants for
descendant/child relations; the following sibling(s) and their
descendants for sibling relations (:has(+ .a .b)'s subject lives
inside the next sibling).
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 |
# File 'lib/dommy/internal/selector_matcher.rb', line 398 def relative_candidates(element, combinator) case combinator when :next_sibling sib = element.next_element_sibling sib ? [sib] + element_descendants(sib) : [] when :subsequent_sibling out = [] sib = element.next_element_sibling while sib out << sib out.concat(element_descendants(sib)) sib = sib.next_element_sibling end out else # :descendant / :child element_descendants(element) end end |
.requirable_element?(element) ⇒ Boolean
:required / :optional apply to input / select / textarea per the
required attribute.
685 686 687 |
# File 'lib/dommy/internal/selector_matcher.rb', line 685 def requirable_element?(element) %w[input select textarea].include?(element.local_name.to_s.downcase) end |
.same_type?(a, b) ⇒ Boolean
466 467 468 |
# File 'lib/dommy/internal/selector_matcher.rb', line 466 def same_type?(a, b) a.namespace_uri == b.namespace_uri && a.local_name == b.local_name end |
.static_prefilters(selector_ast) ⇒ Object
One [kind, value] pre-filter per complex selector — taken from its subject (rightmost) compound. nil when ANY subject lacks a static id/class/attribute to filter on (a universal- or pseudo-only subject), so the whole query falls back to the Ruby matcher.
523 524 525 526 527 528 529 530 |
# File 'lib/dommy/internal/selector_matcher.rb', line 523 def static_prefilters(selector_ast) selector_ast.selectors.map do |complex| compound = complex.parts.last.compound return nil if compound.pseudo_element prefilter_for(compound) || (return nil) end end |
.text_node_content?(node) ⇒ Boolean
629 630 631 |
# File 'lib/dommy/internal/selector_matcher.rb', line 629 def text_node_content?(node) node.respond_to?(:node_type) && node.node_type == 3 && !node.text_content.to_s.empty? end |
.validation_candidate?(element) ⇒ Boolean
A candidate for constraint validation: a form-associated control whose
willValidate is true (not disabled / readonly / barred).
653 654 655 |
# File 'lib/dommy/internal/selector_matcher.rb', line 653 def validation_candidate?(element) element.respond_to?(:will_validate) && element.respond_to?(:validity) && element.will_validate end |