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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +6 -6
- data/.gitignore +17 -3
- data/CHANGELOG.md +19 -1
- data/Gemfile +2 -2
- data/examples/css_analyzer/analyzer.rb +9 -3
- data/examples/css_analyzer/analyzers/base.rb +1 -1
- data/ext/cataract/cataract.c +247 -454
- data/ext/cataract/cataract.h +126 -15
- data/ext/cataract/css_parser.c +782 -788
- data/ext/cataract/extconf.rb +1 -2
- data/ext/cataract/flatten.c +279 -991
- data/ext/cataract/shorthand_expander.c +80 -102
- data/ext/cataract_color/color_conversion.c +1 -1
- data/lib/cataract/declarations.rb +13 -7
- data/lib/cataract/import_resolver.rb +36 -16
- data/lib/cataract/pure/byte_constants.rb +0 -1
- data/lib/cataract/pure/flatten.rb +143 -145
- data/lib/cataract/pure/parser.rb +495 -422
- data/lib/cataract/pure/serializer.rb +109 -104
- data/lib/cataract/pure/specificity.rb +112 -156
- data/lib/cataract/pure.rb +0 -7
- data/lib/cataract/stylesheet.rb +460 -412
- data/lib/cataract/version.rb +1 -1
- metadata +2 -13
- data/ext/cataract/import_scanner.c +0 -174
- data/ext/cataract_old/cataract.c +0 -393
- data/ext/cataract_old/cataract.h +0 -250
- data/ext/cataract_old/css_parser.c +0 -933
- data/ext/cataract_old/extconf.rb +0 -67
- data/ext/cataract_old/import_scanner.c +0 -174
- data/ext/cataract_old/merge.c +0 -776
- data/ext/cataract_old/shorthand_expander.c +0 -902
- data/ext/cataract_old/specificity.c +0 -213
- data/ext/cataract_old/stylesheet.c +0 -290
- data/ext/cataract_old/value_splitter.c +0 -116
|
@@ -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
|
-
|
|
95
|
+
parent_rule_id = _rule_parent_id(rule)
|
|
96
|
+
next unless parent_rule_id
|
|
67
97
|
|
|
68
|
-
parent_id =
|
|
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
|
|
116
|
+
next if _rule_parent_id(rule)
|
|
87
117
|
|
|
88
|
-
rule_media_query_id = rule.
|
|
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 =
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
209
|
+
# - Always add space if the parent had declarations
|
|
171
210
|
# - Add space between nested rules (not before first if no declarations)
|
|
172
|
-
if
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
#
|
|
213
|
-
|
|
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.
|
|
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 =
|
|
380
|
-
|
|
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
|
-
|
|
565
|
+
parent_rule_id = _rule_parent_id(rule)
|
|
566
|
+
next unless parent_rule_id
|
|
552
567
|
|
|
553
|
-
parent_id =
|
|
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
|
|
585
|
+
next if _rule_parent_id(rule)
|
|
571
586
|
|
|
572
|
-
rule_media_query_id = rule.
|
|
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 =
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
657
|
-
|
|
658
|
-
|
|
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 =
|
|
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 << "
|
|
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
|
-
#
|
|
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 << "
|
|
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 << "
|
|
711
|
+
result << " }\n"
|
|
691
712
|
end
|
|
692
713
|
|
|
693
714
|
result << indent
|
|
694
|
-
result << "
|
|
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 << "
|
|
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
|
-
#
|
|
705
|
-
|
|
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 << "
|
|
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, :
|
|
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
|
|
46
|
-
|
|
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
|
|
57
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
#
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|