Module: Dommy::Internal::SelectorParser

Defined in:
lib/dommy/internal/selector_parser.rb

Overview

A CSS Selectors (Level 4) validator: parses a selector string against the grammar and raises on anything syntactically invalid, so querySelector/querySelectorAll/matches/closest throw a SyntaxError for exactly the inputs the spec requires — cases Nokogiri's CSS parser silently accepts ([*=test], div % p, ..x) or rejects with the wrong error.

parse! returns the AST that SelectorMatcher matches against (it both validates and is the matcher's front end). It is a hand-written tokenizer + recursive-descent parser covering the productions the Selectors spec (and the WPT corpus) exercise: selector lists, combinators, type/universal selectors with namespace prefixes, id/class, attribute selectors (with matchers and case flags), and pseudo-classes / pseudo-elements (functional and simple). Because querySelector has no namespace declarations, any named namespace prefix is "undeclared" → a SyntaxError (only *|, |, and the default empty prefix are allowed).

Defined Under Namespace

Classes: InvalidSelector, Parser

Constant Summary collapse

KNOWN_PSEUDO_ELEMENTS =

Pseudo-elements (used with ::, plus the four legacy : forms). A ::x outside this set is an unknown pseudo-element → SyntaxError.

%w[
  before after first-line first-letter selection placeholder marker backdrop
  slotted cue file-selector-button first-letter grammar-error spelling-error
  target-text highlight part view-transition view-transition-group
  view-transition-image-pair view-transition-old view-transition-new
].to_set.freeze
SELECTOR_LIST_FUNCTIONS =

Functional pseudo-classes (followed by (...)). slotted/cue/part are functional pseudo-elements handled in the :: path.

%w[not is where has matches].to_set.freeze
NTH_FUNCTIONS =
%w[nth-child nth-last-child nth-of-type nth-last-of-type nth-col nth-last-col].to_set.freeze
IDENT_FUNCTIONS =
%w[lang dir].to_set.freeze
NESTED_SELECTOR_FUNCTIONS =
%w[host host-context current].to_set.freeze
AST_CACHE_CAP =

A parsed AST is a pure function of (selector string, namespaces) and never goes stale as the DOM mutates (unlike the query-result caches, which tag entries with style_generation). So memoize globally: matches? / closest re-parse on every call, and query* re-parse on every cache miss (i.e. after any mutation), so the same handful of selectors are otherwise re-parsed constantly. Bounded via the same "clear at cap" idiom as the query caches. Lock-free plain Hash: parse! is pure Ruby (never releases the GVL), so a read/write is atomic under the GVL — a benign duplicate parse is the worst a race can cause, matching the existing per-document caches.

2048

Class Method Summary collapse

Class Method Details

.ast_cache_store(cache, key, value) ⇒ Object



84
85
86
87
# File 'lib/dommy/internal/selector_parser.rb', line 84

def ast_cache_store(cache, key, value)
  cache.clear if cache.size >= AST_CACHE_CAP
  cache[key] = value
end

.matchable_selector(selector) ⇒ Object

Return selector with the comma-clauses whose subject is a pseudo-element removed (::before, :first-line — they match no element, so dropping them is what querySelector should do; the backend would otherwise error or mis-parse ::). If EVERY clause is a pseudo-element, returns a selector that matches nothing. Assumes selector is already known valid. Plain selectors (no :) are returned untouched without re-parsing.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/dommy/internal/selector_parser.rb', line 110

def matchable_selector(selector)
  s = selector.to_s
  return s unless s.include?(":")

  parser = Parser.new(s)
  parser.parse_selector_list!
  clauses = parser.clauses
  return s unless clauses.any? { |c| c[:pseudo_subject] }

  kept = clauses.reject { |c| c[:pseudo_subject] }
  kept.empty? ? ":not(*)" : kept.map { |c| c[:text] }.join(", ")
rescue InvalidSelector
  s
end

.new_parser(string, namespaces = nil) ⇒ Object



125
126
127
# File 'lib/dommy/internal/selector_parser.rb', line 125

def new_parser(string, namespaces = nil)
  Parser.new(string, namespaces: namespaces)
end

.parse!(selector, namespaces: nil) ⇒ Object

namespaces maps a prefix String to its URI (with the symbol key :default for the default namespace), letting svg|rect resolve in a stylesheet that declared @namespace. nil/empty (the DOM querySelector path) keeps any named prefix undeclared — a SyntaxError, as before.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/dommy/internal/selector_parser.rb', line 60

def parse!(selector, namespaces: nil)
  key = [selector.to_s, namespaces]
  cache = (@ast_cache ||= {})
  if cache.key?(key)
    cached = cache[key]
    # An invalid selector is memoized as its SyntaxError so a repeat still
    # throws (the message embeds the selector, so re-raising is exact).
    raise cached if cached.is_a?(::Dommy::DOMException::SyntaxError)

    return cached
  end

  ast =
    begin
      new_parser(selector.to_s, namespaces).parse_selector_list!
    rescue InvalidSelector => e
      error = ::Dommy::DOMException::SyntaxError.new("'#{selector}' is not a valid selector: #{e.message}")
      ast_cache_store(cache, key, error)
      raise error
    end
  ast_cache_store(cache, key, ast)
  ast
end

.valid?(selector) ⇒ Boolean

True when selector parses cleanly (no raise).

Returns:

  • (Boolean)


97
98
99
100
101
102
# File 'lib/dommy/internal/selector_parser.rb', line 97

def valid?(selector)
  validate!(selector)
  true
rescue ::Dommy::DOMException::SyntaxError
  false
end

.validate!(selector) ⇒ Object

Validate selector; raise DOMException::SyntaxError if it is not a valid selector list, else return the original string.



91
92
93
94
# File 'lib/dommy/internal/selector_parser.rb', line 91

def validate!(selector)
  parse!(selector)
  selector
end