cataract 0.2.5 → 0.3.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.
@@ -38,6 +38,35 @@ module Cataract
38
38
  end
39
39
  end
40
40
 
41
+ # @api private
42
+ # Helper: does the rule's media query differ from the currently open media
43
+ # block, requiring a new "@media ... {" to be opened? Comma-separated
44
+ # lists are compared by list id; single queries are compared by content
45
+ # (type + conditions), since two separately-parsed queries with the same
46
+ # content should still be grouped under one block.
47
+ # @return [Boolean]
48
+ def self._needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
49
+ rule_media_query_list_id)
50
+ if rule_media_query_list_id
51
+ current_media_query_list_id != rule_media_query_list_id
52
+ else
53
+ !current_media_query ||
54
+ current_media_query.type != rule_media_query.type ||
55
+ current_media_query.conditions != rule_media_query.conditions
56
+ end
57
+ end
58
+
59
+ # @api private
60
+ # Helper: read a rule's parent_rule_id, guarding against AtRule (which has
61
+ # no such member - only Rule participates in CSS nesting, AtRule never is
62
+ # nested via '&' nesting). Calling .parent_rule_id directly on an AtRule
63
+ # raises NoMethodError.
64
+ # @param rule [Rule, AtRule] The rule to check
65
+ # @return [Integer, nil] parent_rule_id, or nil if the rule isn't a Rule
66
+ def self._rule_parent_id(rule)
67
+ rule.is_a?(Rule) ? rule.parent_rule_id : nil
68
+ end
69
+
41
70
  # Serialize stylesheet to compact CSS string
42
71
  #
43
72
  # @param rules [Array<Rule>] Array of rules
@@ -63,9 +92,10 @@ module Cataract
63
92
  # Build parent-child relationships
64
93
  rule_children = {}
65
94
  rules.each do |rule|
66
- next unless rule.parent_rule_id
95
+ parent_rule_id = _rule_parent_id(rule)
96
+ next unless parent_rule_id
67
97
 
68
- parent_id = rule.parent_rule_id.is_a?(Integer) ? rule.parent_rule_id : rule.parent_rule_id.to_i
98
+ parent_id = parent_rule_id.is_a?(Integer) ? parent_rule_id : parent_rule_id.to_i
69
99
  rule_children[parent_id] ||= []
70
100
  rule_children[parent_id] << rule
71
101
  end
@@ -83,9 +113,9 @@ module Cataract
83
113
 
84
114
  rules.each do |rule|
85
115
  # Skip rules that have a parent (they'll be serialized as nested)
86
- next if rule.parent_rule_id
116
+ next if _rule_parent_id(rule)
87
117
 
88
- rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
118
+ rule_media_query_id = rule.media_query_id
89
119
  rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
90
120
  rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
91
121
 
@@ -101,13 +131,8 @@ module Cataract
101
131
  # Check if we need to open a new media block
102
132
  # For lists: compare list_id
103
133
  # For single queries: compare by content (type + conditions)
104
- needs_new_block = if rule_media_query_list_id
105
- current_media_query_list_id != rule_media_query_list_id
106
- else
107
- !current_media_query ||
108
- current_media_query.type != rule_media_query.type ||
109
- current_media_query.conditions != rule_media_query.conditions
110
- end
134
+ needs_new_block = _needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
135
+ rule_media_query_list_id)
111
136
 
112
137
  if needs_new_block
113
138
  if in_media_block
@@ -152,6 +177,14 @@ module Cataract
152
177
 
153
178
  # Helper: serialize a rule with its nested children
154
179
  def self._serialize_rule_with_nesting(result, rule, rule_children, media_queries)
180
+ # Check if this is an AtRule - it can never have nested children of its
181
+ # own via rule_children (only Rule participates in CSS nesting), so it
182
+ # always serializes the same way regardless of has_nesting
183
+ if rule.is_a?(AtRule)
184
+ _serialize_at_rule(result, rule)
185
+ return
186
+ end
187
+
155
188
  # Start selector
156
189
  result << "#{rule.selector} { "
157
190
 
@@ -161,34 +194,33 @@ module Cataract
161
194
  _serialize_declarations(result, rule.declarations)
162
195
  end
163
196
 
164
- # Get nested children for this rule
165
- children = rule_children[rule.id] || []
197
+ _serialize_children(result, rule.selector, rule_children[rule.id] || [], rule_children, media_queries, has_declarations)
166
198
 
167
- # Serialize nested children
199
+ result << " }\n"
200
+ end
201
+
202
+ # Helper: recursively serialize a rule's nested children. CSS nesting can go
203
+ # as deep as the parser allows (MAX_PARSE_DEPTH), so this recurses rather
204
+ # than hand-unrolling a fixed number of levels - a nested rule's own nested
205
+ # rules are found the same way its parent's were, via rule_children[id].
206
+ def self._serialize_children(result, parent_selector, children, rule_children, media_queries, parent_has_declarations)
168
207
  children.each_with_index do |child, index|
169
208
  # Add space before nested content
170
- # - Always add space if we had declarations
209
+ # - Always add space if the parent had declarations
171
210
  # - Add space between nested rules (not before first if no declarations)
172
- if has_declarations || index > 0
211
+ if parent_has_declarations || index > 0
173
212
  result << ' '
174
213
  end
175
214
 
176
- # Determine if we need to reconstruct the nested selector with &
177
- nested_selector = _reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
178
-
179
215
  # Check if this child has @media nesting (parent_rule_id present but nesting_style is nil)
180
216
  if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
181
217
  # This is a nested @media rule
182
218
  mq = media_queries[child.media_query_id]
183
- media_query_string = if mq.conditions
184
- mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
185
- else
186
- mq.type.to_s
187
- end
219
+ media_query_string = _build_media_query_string(mq, nil, nil, media_queries)
188
220
  result << "@media #{media_query_string} { "
189
221
  _serialize_declarations(result, child.declarations)
190
222
 
191
- # Recursively serialize any children of this @media rule
223
+ # Serialize any children of this @media rule
192
224
  media_children = rule_children[child.id] || []
193
225
  media_children.each_with_index do |media_child, media_idx|
194
226
  result << ' ' if media_idx > 0 || !child.declarations.empty?
@@ -205,31 +237,18 @@ module Cataract
205
237
 
206
238
  result << ' }'
207
239
  else
208
- # Regular nested selector
240
+ # Regular nested selector - determine if we need to reconstruct it with &
241
+ nested_selector = _reconstruct_nested_selector(parent_selector, child.selector, child.nesting_style)
242
+
209
243
  result << "#{nested_selector} { "
210
244
  _serialize_declarations(result, child.declarations)
211
245
 
212
- # Recursively serialize any children of this nested rule
213
- grandchildren = rule_children[child.id] || []
214
- grandchildren.each_with_index do |grandchild, grandchild_idx|
215
- result << ' ' if grandchild_idx > 0 || !child.declarations.empty?
216
-
217
- nested_grandchild_selector = _reconstruct_nested_selector(
218
- child.selector,
219
- grandchild.selector,
220
- grandchild.nesting_style
221
- )
222
-
223
- result << "#{nested_grandchild_selector} { "
224
- _serialize_declarations(result, grandchild.declarations)
225
- result << ' }'
226
- end
246
+ # Recurse into this child's own nested children, however deep they go
247
+ _serialize_children(result, child.selector, rule_children[child.id] || [], rule_children, media_queries, !child.declarations.empty?)
227
248
 
228
249
  result << ' }'
229
250
  end
230
251
  end
231
-
232
- result << " }\n"
233
252
  end
234
253
 
235
254
  # Reconstruct nested selector representation
@@ -328,7 +347,7 @@ module Cataract
328
347
  # Skip if already processed (when grouped)
329
348
  next if processed_rule_ids[rule.id]
330
349
 
331
- rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
350
+ rule_media_query_id = rule.media_query_id
332
351
  rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
333
352
  rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
334
353
  is_first_rule = (rule_index == 0)
@@ -376,13 +395,8 @@ module Cataract
376
395
  # This rule is in a media query
377
396
  # For lists: compare list_id
378
397
  # For single queries: compare by content (type + conditions)
379
- needs_new_block = if rule_media_query_list_id
380
- current_media_query_list_id != rule_media_query_list_id
381
- else
382
- !current_media_query ||
383
- current_media_query.type != rule_media_query.type ||
384
- current_media_query.conditions != rule_media_query.conditions
385
- end
398
+ needs_new_block = _needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
399
+ rule_media_query_list_id)
386
400
 
387
401
  if needs_new_block
388
402
  # Close previous media block if open
@@ -548,9 +562,10 @@ module Cataract
548
562
  # Build parent-child relationships
549
563
  rule_children = {}
550
564
  rules.each do |rule|
551
- next unless rule.parent_rule_id
565
+ parent_rule_id = _rule_parent_id(rule)
566
+ next unless parent_rule_id
552
567
 
553
- parent_id = rule.parent_rule_id.is_a?(Integer) ? rule.parent_rule_id : rule.parent_rule_id.to_i
568
+ parent_id = parent_rule_id.is_a?(Integer) ? parent_rule_id : parent_rule_id.to_i
554
569
  rule_children[parent_id] ||= []
555
570
  rule_children[parent_id] << rule
556
571
  end
@@ -567,9 +582,9 @@ module Cataract
567
582
  in_media_block = false
568
583
 
569
584
  rules.each do |rule|
570
- next if rule.parent_rule_id
585
+ next if _rule_parent_id(rule)
571
586
 
572
- rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
587
+ rule_media_query_id = rule.media_query_id
573
588
  rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
574
589
  rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
575
590
 
@@ -588,13 +603,8 @@ module Cataract
588
603
  else
589
604
  # For lists: compare list_id
590
605
  # For single queries: compare by content (type + conditions)
591
- needs_new_block = if rule_media_query_list_id
592
- current_media_query_list_id != rule_media_query_list_id
593
- else
594
- !current_media_query ||
595
- current_media_query.type != rule_media_query.type ||
596
- current_media_query.conditions != rule_media_query.conditions
597
- end
606
+ needs_new_block = _needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
607
+ rule_media_query_list_id)
598
608
 
599
609
  if needs_new_block
600
610
  if in_media_block
@@ -640,6 +650,14 @@ module Cataract
640
650
 
641
651
  # Helper: serialize a rule with nested children (formatted)
642
652
  def self._serialize_rule_with_nesting_formatted(result, rule, rule_children, indent, media_queries)
653
+ # Check if this is an AtRule - it can never have nested children of its
654
+ # own via rule_children (only Rule participates in CSS nesting), so it
655
+ # always serializes the same way regardless of has_nesting
656
+ if rule.is_a?(AtRule)
657
+ _serialize_at_rule_formatted(result, rule, indent)
658
+ return
659
+ end
660
+
643
661
  # Selector line with opening brace
644
662
  result << indent
645
663
  result << rule.selector
@@ -650,29 +668,32 @@ module Cataract
650
668
  _serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
651
669
  end
652
670
 
653
- # Get nested children
654
- children = rule_children[rule.id] || []
671
+ _serialize_children_formatted(result, rule.selector, rule_children[rule.id] || [], rule_children, media_queries, "#{indent} ")
655
672
 
656
- # Serialize nested children
657
- children.each do |child|
658
- nested_selector = _reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
673
+ # Closing brace
674
+ result << indent
675
+ result << "}\n"
676
+ end
659
677
 
678
+ # Helper: recursively serialize a rule's nested children with indentation.
679
+ # CSS nesting can go as deep as the parser allows (MAX_PARSE_DEPTH), so this
680
+ # recurses rather than hand-unrolling a fixed number of levels - a nested
681
+ # rule's own nested rules are found the same way its parent's were, via
682
+ # rule_children[id], with the indent growing by one level each call.
683
+ def self._serialize_children_formatted(result, parent_selector, children, rule_children, media_queries, indent)
684
+ children.each do |child|
660
685
  if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
661
686
  # Nested @media
662
687
  mq = media_queries[child.media_query_id]
663
- media_query_string = if mq.conditions
664
- mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
665
- else
666
- mq.type.to_s
667
- end
688
+ media_query_string = _build_media_query_string(mq, nil, nil, media_queries)
668
689
  result << indent
669
- result << " @media #{media_query_string} {\n"
690
+ result << "@media #{media_query_string} {\n"
670
691
 
671
692
  unless child.declarations.empty?
672
- _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
693
+ _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
673
694
  end
674
695
 
675
- # Recursively handle media children
696
+ # Handle nested media children
676
697
  media_children = rule_children[child.id] || []
677
698
  media_children.each do |media_child|
678
699
  nested_media_selector = _reconstruct_nested_selector(
@@ -682,51 +703,34 @@ module Cataract
682
703
  )
683
704
 
684
705
  result << indent
685
- result << " #{nested_media_selector} {\n"
706
+ result << " #{nested_media_selector} {\n"
686
707
  unless media_child.declarations.empty?
687
- _serialize_declarations_formatted(result, media_child.declarations, "#{indent} ")
708
+ _serialize_declarations_formatted(result, media_child.declarations, "#{indent} ")
688
709
  end
689
710
  result << indent
690
- result << " }\n"
711
+ result << " }\n"
691
712
  end
692
713
 
693
714
  result << indent
694
- result << " }\n"
715
+ result << "}\n"
695
716
  else
696
717
  # Regular nested selector
718
+ nested_selector = _reconstruct_nested_selector(parent_selector, child.selector, child.nesting_style)
719
+
697
720
  result << indent
698
- result << " #{nested_selector} {\n"
721
+ result << "#{nested_selector} {\n"
699
722
 
700
723
  unless child.declarations.empty?
701
- _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
724
+ _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
702
725
  end
703
726
 
704
- # Recursively handle grandchildren
705
- grandchildren = rule_children[child.id] || []
706
- grandchildren.each do |grandchild|
707
- nested_grandchild_selector = _reconstruct_nested_selector(
708
- child.selector,
709
- grandchild.selector,
710
- grandchild.nesting_style
711
- )
712
-
713
- result << indent
714
- result << " #{nested_grandchild_selector} {\n"
715
- unless grandchild.declarations.empty?
716
- _serialize_declarations_formatted(result, grandchild.declarations, "#{indent} ")
717
- end
718
- result << indent
719
- result << " }\n"
720
- end
727
+ # Recurse into this child's own nested children, however deep they go
728
+ _serialize_children_formatted(result, child.selector, rule_children[child.id] || [], rule_children, media_queries, "#{indent} ")
721
729
 
722
730
  result << indent
723
- result << " }\n"
731
+ result << "}\n"
724
732
  end
725
733
  end
726
-
727
- # Closing brace
728
- result << indent
729
- result << "}\n"
730
734
  end
731
735
 
732
736
  # Helper: serialize a single rule with formatting
@@ -788,7 +792,8 @@ module Cataract
788
792
  end
789
793
 
790
794
  # Mark helper methods as private (public APIs: stylesheet_to_s, stylesheet_to_formatted_s)
791
- private_class_method :_build_media_query_string, :_stylesheet_to_s_without_nesting, :_serialize_rule_with_nesting,
795
+ private_class_method :_build_media_query_string, :_needs_new_media_block?, :_stylesheet_to_s_without_nesting,
796
+ :_serialize_rule_with_nesting,
792
797
  :_reconstruct_nested_selector, :_find_groupable_selectors, :_declarations_equal?,
793
798
  :_serialize_rule, :_serialize_declarations, :_serialize_declarations_formatted,
794
799
  :_serialize_at_rule, :_stylesheet_to_formatted_s_without_nesting, :_serialize_rule_with_nesting_formatted,
@@ -4,6 +4,10 @@
4
4
  # NO REGEXP ALLOWED - char-by-char parsing only
5
5
 
6
6
  module Cataract
7
+ # Legacy CSS2 pseudo-elements written with a single colon (e.g. :before)
8
+ # that must still be counted as pseudo-elements, not pseudo-classes.
9
+ PSEUDO_ELEMENT_KEYWORDS = %w[before after first-line first-letter selection].freeze
10
+
7
11
  # Calculate CSS specificity for a selector
8
12
  #
9
13
  # @param selector [String] CSS selector
@@ -27,180 +31,132 @@ module Cataract
27
31
  i = 0
28
32
  len = selector.length
29
33
 
30
- pseudo_element_kwords = %w[before after first-line first-letter selection]
31
-
32
34
  while i < len
33
35
  byte = selector.getbyte(i)
34
36
 
35
- # Skip whitespace and combinators
36
- if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR ||
37
- byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_COMMA
38
- i += 1
39
- next
40
- end
41
-
42
- # ID selector: #id
43
37
  if byte == BYTE_HASH
44
38
  id_count += 1
45
- i += 1
46
- # Skip the identifier
47
- while i < len && ident_char?(selector.getbyte(i))
48
- i += 1
49
- end
50
- next
51
- end
52
-
53
- # Class selector: .class
54
- if byte == BYTE_DOT
39
+ i = skip_identifier(selector, i + 1, len)
40
+ elsif byte == BYTE_DOT
55
41
  class_count += 1
56
- i += 1
57
- # Skip the identifier
58
- while i < len && ident_char?(selector.getbyte(i))
59
- i += 1
60
- end
61
- next
62
- end
63
-
64
- # Attribute selector: [attr]
65
- if byte == BYTE_LBRACKET
42
+ i = skip_identifier(selector, i + 1, len)
43
+ elsif byte == BYTE_LBRACKET
66
44
  attr_count += 1
67
- i += 1
68
- # Skip to closing bracket
69
- bracket_depth = 1
70
- while i < len && bracket_depth > 0
71
- b = selector.getbyte(i)
72
- if b == BYTE_LBRACKET
73
- bracket_depth += 1
74
- elsif b == BYTE_RBRACKET
75
- bracket_depth -= 1
76
- end
77
- i += 1
78
- end
79
- next
80
- end
81
-
82
- # Pseudo-element (::) or pseudo-class (:)
83
- if byte == BYTE_COLON
84
- i += 1
85
- is_pseudo_element = false
86
-
87
- # Check for double colon (::)
88
- if i < len && selector.getbyte(i) == BYTE_COLON
89
- is_pseudo_element = true
90
- i += 1
91
- end
92
-
93
- # Extract pseudo name
94
- pseudo_start = i
95
- while i < len && ident_char?(selector.getbyte(i))
96
- i += 1
97
- end
98
- pseudo_name = selector[pseudo_start...i]
99
-
100
- # Check for legacy pseudo-elements (single colon but should be double)
101
- is_legacy_pseudo_element = false
102
- if !is_pseudo_element && !pseudo_name.empty?
103
- is_legacy_pseudo_element = pseudo_element_kwords.include?(pseudo_name)
104
- end
105
-
106
- # Check for :not() - it doesn't count itself, but its content does
107
- is_not = (pseudo_name == 'not')
108
-
109
- # Skip function arguments if present
110
- if i < len && selector.getbyte(i) == BYTE_LPAREN
111
- i += 1
112
- paren_depth = 1
113
-
114
- # If it's :not(), calculate specificity of the content
115
- if is_not
116
- not_content_start = i
117
-
118
- # Find closing paren
119
- while i < len && paren_depth > 0
120
- b = selector.getbyte(i)
121
- if b == BYTE_LPAREN
122
- paren_depth += 1
123
- elsif b == BYTE_RPAREN
124
- paren_depth -= 1
125
- end
126
- i += 1 if paren_depth > 0
127
- end
128
-
129
- not_content = selector[not_content_start...i]
130
-
131
- # Recursively calculate specificity of :not() content
132
- unless not_content.empty?
133
- not_specificity = calculate_specificity(not_content)
134
-
135
- # Add :not() content's specificity to our counts
136
- additional_a = not_specificity / 100
137
- additional_b = (not_specificity % 100) / 10
138
- additional_c = not_specificity % 10
139
-
140
- id_count += additional_a
141
- class_count += additional_b
142
- element_count += additional_c
143
- end
144
-
145
- i += 1 # Skip closing paren
146
- else
147
- # Skip other function arguments
148
- while i < len && paren_depth > 0
149
- b = selector.getbyte(i)
150
- if b == BYTE_LPAREN
151
- paren_depth += 1
152
- elsif b == BYTE_RPAREN
153
- paren_depth -= 1
154
- end
155
- i += 1
156
- end
157
-
158
- # Count the pseudo-class/element
159
- if is_pseudo_element || is_legacy_pseudo_element
160
- pseudo_element_count += 1
161
- else
162
- pseudo_class_count += 1
163
- end
164
- end
165
- else
166
- # No function arguments - count the pseudo-class/element
167
- if is_not
168
- # :not without parens is invalid, but don't count it
169
- elsif is_pseudo_element || is_legacy_pseudo_element
45
+ i = skip_attribute_selector(selector, i, len)
46
+ elsif byte == BYTE_COLON
47
+ i, is_not, not_content, counts_as_element = parse_pseudo(selector, i, len)
48
+ if not_content
49
+ # :not() doesn't count itself, but its content does
50
+ not_specificity = calculate_specificity(not_content)
51
+ id_count += not_specificity / 100
52
+ class_count += (not_specificity % 100) / 10
53
+ element_count += not_specificity % 10
54
+ elsif !is_not
55
+ if counts_as_element
170
56
  pseudo_element_count += 1
171
57
  else
172
58
  pseudo_class_count += 1
173
59
  end
174
60
  end
175
- next
176
- end
177
-
178
- # Universal selector: *
179
- if byte == BYTE_ASTERISK
180
- # Universal selector has specificity 0, don't count
61
+ elsif letter?(byte)
62
+ element_count += 1
63
+ i = skip_identifier(selector, i + 1, len)
64
+ else
65
+ # Whitespace, combinators, and the universal selector (*) all have
66
+ # zero specificity - just skip a single byte.
181
67
  i += 1
182
- next
183
68
  end
69
+ end
184
70
 
185
- # Type selector (element name): div, span, etc.
186
- if letter?(byte)
187
- element_count += 1
188
- # Skip the identifier
189
- while i < len && ident_char?(selector.getbyte(i))
190
- i += 1
191
- end
192
- next
193
- end
71
+ # Calculate specificity using W3C formula
72
+ (id_count * 100) +
73
+ ((class_count + attr_count + pseudo_class_count) * 10) +
74
+ ((element_count + pseudo_element_count) * 1)
75
+ end
76
+
77
+ # Advances past an identifier (used after #id, .class, element names, and
78
+ # pseudo names), returning the index of the first non-identifier byte.
79
+ def self.skip_identifier(selector, pos, len)
80
+ pos += 1 while pos < len && ident_char?(selector.getbyte(pos))
81
+ pos
82
+ end
83
+ private_class_method :skip_identifier
194
84
 
195
- # Unknown character, skip it
196
- i += 1
85
+ # Advances past a bracketed [attr] selector, honoring nested brackets,
86
+ # returning the index just after the matching closing bracket.
87
+ def self.skip_attribute_selector(selector, pos, len)
88
+ skip_balanced(selector, pos + 1, len, BYTE_LBRACKET, BYTE_RBRACKET)
89
+ end
90
+ private_class_method :skip_attribute_selector
91
+
92
+ # Advances past a balanced open/close byte pair (already past the opening
93
+ # byte, with depth 1), returning the index just after the matching close.
94
+ def self.skip_balanced(selector, pos, len, open_byte, close_byte)
95
+ depth = 1
96
+ while pos < len && depth > 0
97
+ b = selector.getbyte(pos)
98
+ depth += 1 if b == open_byte
99
+ depth -= 1 if b == close_byte
100
+ pos += 1
101
+ end
102
+ pos
103
+ end
104
+ private_class_method :skip_balanced
105
+
106
+ # Advances past a balanced open/close byte pair (already past the opening
107
+ # byte, with depth 1), returning the index OF the matching close byte
108
+ # (rather than past it), so the caller can capture the content in between.
109
+ def self.find_balanced_close(selector, pos, len, open_byte, close_byte)
110
+ depth = 1
111
+ while pos < len && depth > 0
112
+ b = selector.getbyte(pos)
113
+ depth += 1 if b == open_byte
114
+ depth -= 1 if b == close_byte
115
+ pos += 1 if depth > 0
116
+ end
117
+ pos
118
+ end
119
+ private_class_method :find_balanced_close
120
+
121
+ # Parses a :pseudo-class, ::pseudo-element, or :not(...) token starting at
122
+ # the colon byte. Returns [new_pos, is_not, not_content, counts_as_element]:
123
+ # - new_pos: index just after the fully-consumed token (incl. any (...) args)
124
+ # - is_not: whether this token is :not (which never counts itself)
125
+ # - not_content: the non-empty content of :not(...), or nil otherwise
126
+ # - counts_as_element: whether this token counts toward pseudo-elements
127
+ # (::foo, or a legacy single-colon pseudo-element like :before) rather
128
+ # than pseudo-classes
129
+ def self.parse_pseudo(selector, pos, len)
130
+ pos += 1
131
+ is_pseudo_element = false
132
+ if pos < len && selector.getbyte(pos) == BYTE_COLON
133
+ is_pseudo_element = true
134
+ pos += 1
197
135
  end
198
136
 
199
- # Calculate specificity using W3C formula
200
- specificity = (id_count * 100) +
201
- ((class_count + attr_count + pseudo_class_count) * 10) +
202
- ((element_count + pseudo_element_count) * 1)
137
+ pseudo_start = pos
138
+ pos = skip_identifier(selector, pos, len)
139
+ pseudo_name = selector[pseudo_start...pos]
140
+
141
+ is_legacy_pseudo_element = !is_pseudo_element && !pseudo_name.empty? &&
142
+ PSEUDO_ELEMENT_KEYWORDS.include?(pseudo_name)
143
+ is_not = (pseudo_name == 'not')
144
+ not_content = nil
145
+
146
+ if pos < len && selector.getbyte(pos) == BYTE_LPAREN
147
+ pos += 1
148
+ if is_not
149
+ content_start = pos
150
+ pos = find_balanced_close(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
151
+ content = selector[content_start...pos]
152
+ not_content = content unless content.empty?
153
+ pos += 1 # Skip closing paren
154
+ else
155
+ pos = skip_balanced(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
156
+ end
157
+ end
203
158
 
204
- specificity
159
+ [pos, is_not, not_content, is_pseudo_element || is_legacy_pseudo_element]
205
160
  end
161
+ private_class_method :parse_pseudo
206
162
  end