p_css 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc053cb635310340632520bdf6ef0771e1003b5d2f8582434078667cc9aeb7b4
4
- data.tar.gz: b5e3c26f959dfb92e86126aab069b293796e762fb5388baef6be7c98b7b7499e
3
+ metadata.gz: 9e5c2f5073fa34e7847e6ce57005acbd33a912c544b6ea6971f8c50fc6f9f416
4
+ data.tar.gz: 4b6de8fb7d7919fafdc4e4ba249024d022c628675c0ecc66d65cba85f6132b51
5
5
  SHA512:
6
- metadata.gz: 6803cd828b9d6b2ffeaf7c3c82e75345e7b1165dff0e93e4390ebf9eb4ea7a74bcd45453582562f44bcdb45cc614389c41e60bfc94ba2865421c1cc2ea9399b6
7
- data.tar.gz: e6acedb22e9a43e81014dfff4d65378d88eda70f515936fd9f0962778e2c3aa4786cf608c8713fc0d3985e10dfb6919d77fcfe61cdd85b1263603b290fcbadb2
6
+ metadata.gz: ee478932c614e644236fe532663bf781c72397795db988ea34c92ff02a7659da0e076a2f547397f94b6500168b4fafa9071b728d0dd38b05075dfcf5589201fa
7
+ data.tar.gz: 9b87600e33a0500fd61c0b919e4709795a0cea47755eded8f343adb5341ee4c47a49b5197429cd443102adc88b0ebbcfdb2ee26a7e2a3133387c3650daae44fe
data/lib/css/cascade.rb CHANGED
@@ -8,9 +8,11 @@ module CSS
8
8
  # - cascade sort: `!important` > origin / inline > specificity > source order
9
9
  #
10
10
  # The Stylesheet is compiled once on construction (selectors are
11
- # pre-parsed, specificities pre-computed, and `@media` chains are
12
- # evaluated against the supplied context up-front so non-matching rules
13
- # are dropped). `resolve(element)` is then cheap to call per node.
11
+ # pre-parsed, specificities pre-computed, `@media` chains are
12
+ # evaluated against the supplied context up-front, and rules are
13
+ # indexed by the rightmost compound's strongest anchor id > class >
14
+ # tag > universal). `resolve(element)` then visits only the rules whose
15
+ # anchor could match the element.
14
16
  #
15
17
  # Cascade layers, `@scope` proximity, and Shadow DOM encapsulation are
16
18
  # not modeled — `@layer`, `@supports`, `@container`, `@scope`, and
@@ -25,15 +27,20 @@ module CSS
25
27
  def initialize(stylesheet, context: MediaQueries::Context.default)
26
28
  @context = context
27
29
  @entries = compile(stylesheet)
30
+ @index = build_index(@entries)
28
31
  end
29
32
 
30
33
  # Returns Hash<String, Declaration> of winning declarations.
31
34
  def resolve(element, inline_style: nil)
32
- order = 0
33
- matches = []
35
+ cache = {}
36
+ candidates = collect_candidate_indexes(element, cache)
37
+ order = 0
38
+ matches = []
39
+
40
+ candidates.each do |idx|
41
+ entry = @entries[idx]
42
+ spec = best_matching_specificity(element, entry.selector_pairs, cache)
34
43
 
35
- @entries.each do |entry|
36
- spec = best_matching_specificity(element, entry.selector_pairs)
37
44
  next if spec.nil?
38
45
 
39
46
  entry.declarations.each do |decl|
@@ -54,6 +61,9 @@ module CSS
54
61
 
55
62
  private
56
63
 
64
+ # Compile
65
+ # ----------------------------------------------------------------
66
+
57
67
  def compile(stylesheet)
58
68
  out = []
59
69
  walk(stylesheet.rules, [], out)
@@ -77,7 +87,7 @@ module CSS
77
87
  def register_qualified_rule(rule, media_chain, out)
78
88
  return unless media_chain.all? { MediaQueries::Evaluator.evaluate(it, @context) }
79
89
 
80
- sl = Selectors::Parser.parse_selector_list(rule.prelude)
90
+ sl = Selectors::Parser.parse_selector_list(rule.prelude)
81
91
  pairs = sl.selectors.map { [it, Selectors::SpecificityCalculator.calculate(it)] }
82
92
  decls = rule.block.items.select { it.is_a?(Nodes::Declaration) }
83
93
 
@@ -102,11 +112,97 @@ module CSS
102
112
  # remain unaffected.
103
113
  end
104
114
 
105
- def best_matching_specificity(element, selector_pairs)
115
+ # Index
116
+ # ----------------------------------------------------------------
117
+
118
+ Index = Data.define(:by_id, :by_class, :by_tag, :universal)
119
+
120
+ EMPTY_INDEXES = [].freeze
121
+
122
+ def build_index(entries)
123
+ by_id = {}
124
+ by_class = {}
125
+ by_tag = {}
126
+ universal = []
127
+
128
+ entries.each_with_index do |entry, idx|
129
+ keys = Set.new
130
+
131
+ entry.selector_pairs.each do |sel, _spec|
132
+ key = anchor_key(sel)
133
+
134
+ next if keys.include?(key)
135
+
136
+ keys << key
137
+
138
+ case key.first
139
+ when :id then (by_id[key.last] ||= []) << idx
140
+ when :class then (by_class[key.last] ||= []) << idx
141
+ when :tag then (by_tag[key.last] ||= []) << idx
142
+ when :universal then universal << idx
143
+ end
144
+ end
145
+ end
146
+
147
+ Index.new(
148
+ by_id: by_id.freeze,
149
+ by_class: by_class.freeze,
150
+ by_tag: by_tag.freeze,
151
+ universal: universal.freeze
152
+ )
153
+ end
154
+
155
+ # Picks the strongest anchor in the rightmost compound: id > class >
156
+ # tag > universal. Compounds whose only simple selectors are pseudos
157
+ # (e.g. `:hover`) or attribute matchers fall through to universal —
158
+ # they will be tested against every element, but real-world
159
+ # stylesheets rarely have many such rules.
160
+ def anchor_key(complex_selector)
161
+ compound = complex_selector.compounds.last
162
+
163
+ compound.components.each do |c|
164
+ return [:id, c.name] if c.is_a?(Selectors::IdSelector)
165
+ end
166
+ compound.components.each do |c|
167
+ return [:class, c.name] if c.is_a?(Selectors::ClassSelector)
168
+ end
169
+ compound.components.each do |c|
170
+ return [:tag, c.name.downcase] if c.is_a?(Selectors::TypeSelector)
171
+ end
172
+
173
+ [:universal]
174
+ end
175
+
176
+ # Resolve helpers
177
+ # ----------------------------------------------------------------
178
+
179
+ def collect_candidate_indexes(element, cache)
180
+ seen = Set.new
181
+
182
+ el_id = Selectors::Matcher.id_of(element, cache)
183
+
184
+ if el_id && (bucket = @index.by_id[el_id])
185
+ seen.merge(bucket)
186
+ end
187
+
188
+ Selectors::Matcher.classes_of(element, cache).each do |cls|
189
+ bucket = @index.by_class[cls]
190
+ seen.merge(bucket) if bucket
191
+ end
192
+
193
+ tag_bucket = @index.by_tag[Selectors::Matcher.tag_of(element, cache)]
194
+ seen.merge(tag_bucket) if tag_bucket
195
+
196
+ seen.merge(@index.universal)
197
+
198
+ seen.to_a.sort!
199
+ end
200
+
201
+ def best_matching_specificity(element, selector_pairs, cache)
106
202
  best = nil
107
203
 
108
204
  selector_pairs.each do |sel, spec|
109
- next unless Selectors::Matcher.matches?(element, sel)
205
+ next unless Selectors::Matcher.matches?(element, sel, cache: cache)
110
206
 
111
207
  best = spec if best.nil? || spec > best
112
208
  end
@@ -114,24 +210,20 @@ module CSS
114
210
  best
115
211
  end
116
212
 
117
- # Single-pass: keep the running winner per property name. Cheaper than
118
- # group_by + max_by, and more importantly avoids allocating a fresh
119
- # comparison key per declaration.
213
+ # Single-pass running max per property name. Cheaper than group_by +
214
+ # max_by, and avoids allocating a fresh comparison key per
215
+ # declaration.
120
216
  def pick_winners(matches)
121
- winners = {}
122
- winner_matches = {}
217
+ best = {}
123
218
 
124
219
  matches.each do |m|
125
220
  name = m.declaration.name
126
- incumbent = winner_matches[name]
221
+ incumbent = best[name]
127
222
 
128
- if incumbent.nil? || better?(m, incumbent)
129
- winners[name] = m.declaration
130
- winner_matches[name] = m
131
- end
223
+ best[name] = m if incumbent.nil? || better?(m, incumbent)
132
224
  end
133
225
 
134
- winners
226
+ best.transform_values(&:declaration)
135
227
  end
136
228
 
137
229
  # `m` outranks `incumbent` when its priority class is higher, or — at
@@ -26,16 +26,24 @@ module CSS
26
26
  LINK_TAGS = %w[a area link].freeze
27
27
  RO_INPUT_TYPES = %w[hidden range color checkbox radio file submit image reset button].freeze
28
28
 
29
- def matches?(element, selector)
29
+ # Per-element cache used to avoid recomputing tag / id / class set
30
+ # for every selector in a hot loop (e.g. `Cascade#resolve` against
31
+ # hundreds of rules). Keyed by `Object#object_id`; only valid for
32
+ # the duration of a single matcher invocation.
33
+ Context = Struct.new(:tag, :id, :classes, keyword_init: true)
34
+
35
+ EMPTY_CLASS_SET = Set.new.freeze
36
+
37
+ def matches?(element, selector, cache: nil)
30
38
  sel = selector.is_a?(String) ? Parser.parse_selector_list(selector) : selector
31
39
 
32
40
  case sel
33
41
  when SelectorList
34
- sel.selectors.any? { match_complex(element, it) }
42
+ sel.selectors.any? { match_complex(element, it, cache) }
35
43
  when ComplexSelector
36
- match_complex(element, sel)
44
+ match_complex(element, sel, cache)
37
45
  when CompoundSelector
38
- match_compound(element, sel)
46
+ match_compound(element, sel, cache)
39
47
  else
40
48
  raise ArgumentError, "expected a selector node or string, got #{sel.class}"
41
49
  end
@@ -46,32 +54,32 @@ module CSS
46
54
  # Walks the complex selector right-to-left starting at the rightmost
47
55
  # compound. Each combinator either succeeds against ancestors /
48
56
  # siblings of the current candidate or fails the whole match.
49
- def match_complex(element, complex)
50
- match_at(element, complex, complex.compounds.size - 1)
57
+ def match_complex(element, complex, cache)
58
+ match_at(element, complex, complex.compounds.size - 1, cache)
51
59
  end
52
60
 
53
- def match_at(element, complex, index)
61
+ def match_at(element, complex, index, cache)
54
62
  return false if element.nil?
55
- return false unless match_compound(element, complex.compounds[index])
63
+ return false unless match_compound(element, complex.compounds[index], cache)
56
64
  return true if index.zero?
57
65
 
58
66
  prev = index - 1
59
67
 
60
68
  case complex.combinators[prev]
61
- when :descendant then walk_until_match(element, complex, prev, :parent_element)
62
- when :child then match_at(parent_element(element), complex, prev)
63
- when :next_sibling then match_at(previous_element(element), complex, prev)
64
- when :subsequent_sibling then walk_until_match(element, complex, prev, :previous_element)
69
+ when :descendant then walk_until_match(element, complex, prev, :parent_element, cache)
70
+ when :child then match_at(parent_element(element), complex, prev, cache)
71
+ when :next_sibling then match_at(previous_element(element), complex, prev, cache)
72
+ when :subsequent_sibling then walk_until_match(element, complex, prev, :previous_element, cache)
65
73
  end
66
74
  end
67
75
 
68
76
  # Steps along the DOM via `direction` until a candidate matches the
69
77
  # remaining complex selector or the chain runs out.
70
- def walk_until_match(element, complex, index, direction)
78
+ def walk_until_match(element, complex, index, direction, cache)
71
79
  candidate = send(direction, element)
72
80
 
73
81
  while candidate
74
- return true if match_at(candidate, complex, index)
82
+ return true if match_at(candidate, complex, index, cache)
75
83
 
76
84
  candidate = send(direction, candidate)
77
85
  end
@@ -79,24 +87,63 @@ module CSS
79
87
  false
80
88
  end
81
89
 
82
- def match_compound(element, compound)
83
- compound.components.all? { match_simple(element, it) }
90
+ def match_compound(element, compound, cache)
91
+ compound.components.all? { match_simple(element, it, cache) }
84
92
  end
85
93
 
86
- def match_simple(element, simple)
94
+ def match_simple(element, simple, cache)
87
95
  case simple
88
- when TypeSelector then tag(element).casecmp?(simple.name)
96
+ when TypeSelector then tag_of(element, cache).casecmp?(simple.name)
89
97
  when UniversalSelector then true
90
- when IdSelector then attr(element, 'id') == simple.name
91
- when ClassSelector then class_list(element).include?(simple.name)
98
+ when IdSelector then id_of(element, cache) == simple.name
99
+ when ClassSelector then classes_of(element, cache).include?(simple.name)
92
100
  when AttributeSelector then match_attribute(element, simple)
93
- when PseudoClass then match_pseudo_class(element, simple)
101
+ when PseudoClass then match_pseudo_class(element, simple, cache)
94
102
  when PseudoElement then false
95
103
  when NestingSelector then false
96
104
  else false
97
105
  end
98
106
  end
99
107
 
108
+ # Public — used by `Cascade` for both rule indexing and matching;
109
+ # callers can share a `cache` Hash with `matches?(cache: cache)`
110
+ # so each element pays for its tag / id / class set at most once.
111
+ public
112
+
113
+ def tag_of(element, cache = nil)
114
+ ctx = context_for(element, cache)
115
+ ctx ? ctx.tag : tag(element)
116
+ end
117
+
118
+ def id_of(element, cache = nil)
119
+ ctx = context_for(element, cache)
120
+ ctx ? ctx.id : attr(element, 'id')
121
+ end
122
+
123
+ def classes_of(element, cache = nil)
124
+ ctx = context_for(element, cache)
125
+ ctx ? ctx.classes : build_class_set(element)
126
+ end
127
+
128
+ private
129
+
130
+ def context_for(element, cache)
131
+ return nil if cache.nil?
132
+
133
+ cache[element.object_id] ||= Context.new(
134
+ tag: tag(element),
135
+ id: attr(element, 'id'),
136
+ classes: build_class_set(element)
137
+ )
138
+ end
139
+
140
+ def build_class_set(element)
141
+ v = attr(element, 'class')
142
+ return EMPTY_CLASS_SET if v.nil? || v.empty?
143
+
144
+ v.to_s.split(/\s+/).to_set
145
+ end
146
+
100
147
  # Attribute matching ----------------------------------------------
101
148
 
102
149
  def match_attribute(element, attr_sel)
@@ -125,10 +172,10 @@ module CSS
125
172
 
126
173
  # Pseudo-class matching -------------------------------------------
127
174
 
128
- def match_pseudo_class(element, pc)
175
+ def match_pseudo_class(element, pc, cache)
129
176
  case pc.name.downcase
130
- when 'is', 'where', 'matches' then match_selector_list_arg(element, pc.argument)
131
- when 'not' then negate_selector_list_arg(element, pc.argument)
177
+ when 'is', 'where', 'matches' then match_selector_list_arg(element, pc.argument, cache)
178
+ when 'not' then negate_selector_list_arg(element, pc.argument, cache)
132
179
  when 'has' then false
133
180
  when 'root' then parent_element(element).nil?
134
181
  when 'scope' then parent_element(element).nil?
@@ -159,12 +206,12 @@ module CSS
159
206
  end
160
207
  end
161
208
 
162
- def match_selector_list_arg(element, arg)
163
- arg.is_a?(SelectorList) && matches?(element, arg)
209
+ def match_selector_list_arg(element, arg, cache)
210
+ arg.is_a?(SelectorList) && matches?(element, arg, cache: cache)
164
211
  end
165
212
 
166
- def negate_selector_list_arg(element, arg)
167
- arg.is_a?(SelectorList) && !matches?(element, arg)
213
+ def negate_selector_list_arg(element, arg, cache)
214
+ arg.is_a?(SelectorList) && !matches?(element, arg, cache: cache)
168
215
  end
169
216
 
170
217
  def match_nth(element, anb, of_type:, from_end:)
data/lib/css/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module CSS
2
- VERSION = '0.1.0'
2
+ VERSION = '0.1.1'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: p_css
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima