cataract 0.2.5 → 0.4.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -6
  4. data/.rubocop_todo.yml +11 -19
  5. data/BENCHMARKS.md +40 -40
  6. data/CHANGELOG.md +28 -1
  7. data/Gemfile +2 -2
  8. data/examples/css_analyzer/analyzer.rb +9 -3
  9. data/examples/css_analyzer/analyzers/base.rb +1 -1
  10. data/ext/cataract/cataract.c +312 -550
  11. data/ext/cataract/cataract.h +126 -16
  12. data/ext/cataract/css_parser.c +782 -806
  13. data/ext/cataract/extconf.rb +1 -2
  14. data/ext/cataract/flatten.c +279 -991
  15. data/ext/cataract/shorthand_expander.c +80 -102
  16. data/ext/cataract_color/color_conversion.c +1 -1
  17. data/lib/cataract/declarations.rb +26 -10
  18. data/lib/cataract/import_resolver.rb +36 -16
  19. data/lib/cataract/native.rb +30 -0
  20. data/lib/cataract/pure/byte_constants.rb +70 -67
  21. data/lib/cataract/pure/declarations.rb +125 -0
  22. data/lib/cataract/pure/flatten.rb +1195 -1182
  23. data/lib/cataract/pure/parser.rb +1721 -1652
  24. data/lib/cataract/pure/serializer.rb +575 -710
  25. data/lib/cataract/pure/specificity.rb +152 -175
  26. data/lib/cataract/pure.rb +73 -108
  27. data/lib/cataract/rule.rb +6 -3
  28. data/lib/cataract/stylesheet.rb +475 -415
  29. data/lib/cataract/version.rb +1 -1
  30. data/lib/cataract.rb +41 -30
  31. data/lib/tasks/profile.rake +6 -3
  32. metadata +4 -14
  33. data/ext/cataract/import_scanner.c +0 -174
  34. data/ext/cataract_old/cataract.c +0 -393
  35. data/ext/cataract_old/cataract.h +0 -250
  36. data/ext/cataract_old/css_parser.c +0 -933
  37. data/ext/cataract_old/extconf.rb +0 -67
  38. data/ext/cataract_old/import_scanner.c +0 -174
  39. data/ext/cataract_old/merge.c +0 -776
  40. data/ext/cataract_old/shorthand_expander.c +0 -902
  41. data/ext/cataract_old/specificity.c +0 -213
  42. data/ext/cataract_old/stylesheet.c +0 -290
  43. data/ext/cataract_old/value_splitter.c +0 -116
  44. data/lib/cataract/pure/helpers.rb +0 -35
@@ -15,1935 +15,2004 @@
15
15
  # matter in a hot parsing loop.
16
16
 
17
17
  module Cataract
18
- # Pure Ruby CSS parser - char-by-char, NO REGEXP
19
- class Parser
20
- # Maximum parse depth (prevent infinite recursion)
21
- MAX_PARSE_DEPTH = 10
22
-
23
- # Maximum media queries (prevent symbol table exhaustion)
24
- MAX_MEDIA_QUERIES = 1000
25
-
26
- # Maximum property name/value lengths
27
- MAX_PROPERTY_NAME_LENGTH = 256
28
- MAX_PROPERTY_VALUE_LENGTH = 32_768
29
-
30
- AT_RULE_TYPES = %w[supports layer container scope].freeze
31
-
32
- # Extract substring and force specified encoding
33
- # Per CSS spec, charset detection happens at byte-stream level before parsing.
34
- # All parsing operations treat content as UTF-8 (spec requires fallback to UTF-8).
35
- # This prevents ArgumentError on broken/invalid encodings when calling string methods.
36
- # Optional encoding parameter (default: 'UTF-8', use 'US-ASCII' for property names)
37
- def byteslice_encoded(start, length, encoding: 'UTF-8')
38
- @_css.byteslice(start, length).force_encoding(encoding)
39
- end
40
-
41
- # Helper: Case-insensitive ASCII byte comparison
42
- # Compares bytes at given position with ASCII pattern (case-insensitive)
43
- # Safe to use even if position is in middle of multi-byte UTF-8 characters
44
- # Returns true if match, false otherwise
45
- def match_ascii_ci?(str, pos, pattern)
46
- pattern_len = pattern.bytesize
47
- return false if pos + pattern_len > str.bytesize
48
-
49
- i = 0
50
- while i < pattern_len
51
- str_byte = str.getbyte(pos + i)
52
- pat_byte = pattern.getbyte(i)
53
-
54
- # Convert both to lowercase for comparison (ASCII only: A-Z -> a-z)
55
- str_byte += BYTE_CASE_DIFF if str_byte >= BYTE_UPPER_A && str_byte <= BYTE_UPPER_Z
56
- pat_byte += BYTE_CASE_DIFF if pat_byte >= BYTE_UPPER_A && pat_byte <= BYTE_UPPER_Z
57
-
58
- return false if str_byte != pat_byte
59
-
60
- i += 1
61
- end
62
-
63
- true
64
- end
65
-
66
- def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
67
- # Type validation
68
- raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
69
-
70
- # Private: Internal parsing state
71
- @_css = css_string.dup.freeze
72
- @_pos = 0
73
- @_len = @_css.bytesize
74
- @_parent_media_sym = parent_media_sym
75
- @_parent_media_query_id = parent_media_query_id
76
- @_depth = depth # Current recursion depth (passed from parent parser)
77
-
78
- # Private: Parser options with defaults
79
- @_parser_options = {
80
- selector_lists: true,
81
- base_uri: nil,
82
- absolute_paths: false,
83
- uri_resolver: nil,
84
- raise_parse_errors: false
85
- }.merge(parser_options)
86
-
87
- # Private: Extract options to ivars to avoid repeated hash lookups in hot path
88
- @_selector_lists_enabled = @_parser_options[:selector_lists]
89
- @_base_uri = @_parser_options[:base_uri]
90
- @_absolute_paths = @_parser_options[:absolute_paths]
91
- @_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
92
-
93
- # Parse error handling options - extract to ivars for hot path performance
94
- @_raise_parse_errors = @_parser_options[:raise_parse_errors]
95
- if @_raise_parse_errors.is_a?(Hash)
96
- # Granular control - default all to false (opt-in)
97
- @_check_empty_values = @_raise_parse_errors[:empty_values] || false
98
- @_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
99
- @_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
100
- @_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
101
- @_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
102
- @_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
103
- elsif @_raise_parse_errors == true
104
- # Enable all error checks
105
- @_check_empty_values = true
106
- @_check_malformed_declarations = true
107
- @_check_invalid_selectors = true
108
- @_check_invalid_selector_syntax = true
109
- @_check_malformed_at_rules = true
110
- @_check_unclosed_blocks = true
111
- else
112
- # Disabled
113
- @_check_empty_values = false
114
- @_check_malformed_declarations = false
115
- @_check_invalid_selectors = false
116
- @_check_invalid_selector_syntax = false
117
- @_check_malformed_at_rules = false
118
- @_check_unclosed_blocks = false
119
- end
120
-
121
- # Private: Internal counters
122
- @_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
123
- @_next_selector_list_id = 0 # Counter for selector list IDs
124
- @_next_media_query_list_id = 0 # Counter for media query list IDs
125
- @_rule_id_counter = 0 # Next rule ID (0-indexed)
126
- @_media_query_count = 0 # Safety limit
127
-
128
- # Public: Parser results (returned in parse result hash)
129
- @rules = [] # Flat array of Rule structs
130
- @media_queries = [] # Array of MediaQuery objects
131
- @media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
132
- @imports = [] # Array of ImportStatement structs
133
- @charset = nil # @charset declaration
134
-
135
- # Semi-private: Internal state exposed with _ prefix in result
136
- @_selector_lists = {} # Hash: list_id => Array of rule IDs
137
- @_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
138
- @_has_nesting = false # Set to true if any nested rules found
139
- end
18
+ module Backends
19
+ class PureImpl
20
+ # Pure Ruby CSS parser - char-by-char, NO REGEXP
21
+ class Parser
22
+ # Maximum parse depth (prevent infinite recursion)
23
+ MAX_PARSE_DEPTH = 10
24
+
25
+ # Maximum media queries (prevent symbol table exhaustion)
26
+ MAX_MEDIA_QUERIES = 1000
27
+
28
+ AT_RULE_TYPES = %w[supports layer container scope].freeze
29
+
30
+ # Extract substring and force specified encoding
31
+ # Per CSS spec, charset detection happens at byte-stream level before parsing.
32
+ # All parsing operations treat content as UTF-8 (spec requires fallback to UTF-8).
33
+ # This prevents ArgumentError on broken/invalid encodings when calling string methods.
34
+ # Optional encoding parameter (default: 'UTF-8', use 'US-ASCII' for property names)
35
+ def byteslice_encoded(start, length, encoding: 'UTF-8')
36
+ @_css.byteslice(start, length).force_encoding(encoding)
37
+ end
38
+
39
+ # Helper: Case-insensitive ASCII byte comparison
40
+ # Compares bytes at given position with ASCII pattern (case-insensitive)
41
+ # Safe to use even if position is in middle of multi-byte UTF-8 characters
42
+ # Returns true if match, false otherwise
43
+ def match_ascii_ci?(str, pos, pattern)
44
+ pattern_len = pattern.bytesize
45
+ return false if pos + pattern_len > str.bytesize
46
+
47
+ i = 0
48
+ while i < pattern_len
49
+ str_byte = str.getbyte(pos + i)
50
+ pat_byte = pattern.getbyte(i)
51
+
52
+ # Convert both to lowercase for comparison (ASCII only: A-Z -> a-z)
53
+ str_byte += BYTE_CASE_DIFF if str_byte >= BYTE_UPPER_A && str_byte <= BYTE_UPPER_Z
54
+ pat_byte += BYTE_CASE_DIFF if pat_byte >= BYTE_UPPER_A && pat_byte <= BYTE_UPPER_Z
55
+
56
+ return false if str_byte != pat_byte
57
+
58
+ i += 1
59
+ end
140
60
 
141
- def parse
142
- # @import statements are now handled in parse_at_rule
143
- # They must come before all rules (except @charset) per CSS spec
61
+ true
62
+ end
63
+
64
+ def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
65
+ # Type validation
66
+ raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
67
+
68
+ # Private: Internal parsing state
69
+ @_css = css_string.dup.freeze
70
+ @_pos = 0
71
+ @_len = @_css.bytesize
72
+ @_parent_media_sym = parent_media_sym
73
+ @_parent_media_query_id = parent_media_query_id
74
+ @_depth = depth # Current recursion depth (passed from parent parser)
75
+
76
+ # Private: Parser options with defaults
77
+ @_parser_options = {
78
+ selector_lists: true,
79
+ base_uri: nil,
80
+ absolute_paths: false,
81
+ uri_resolver: nil,
82
+ raise_parse_errors: false
83
+ }.merge(parser_options)
84
+
85
+ # Private: Extract options to ivars to avoid repeated hash lookups in hot path
86
+ @_selector_lists_enabled = @_parser_options[:selector_lists]
87
+ @_base_uri = @_parser_options[:base_uri]
88
+ @_absolute_paths = @_parser_options[:absolute_paths]
89
+ @_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
90
+
91
+ # Parse error handling options - extract to ivars for hot path performance
92
+ @_raise_parse_errors = @_parser_options[:raise_parse_errors]
93
+ if @_raise_parse_errors.is_a?(Hash)
94
+ # Granular control - default all to false (opt-in)
95
+ @_check_empty_values = @_raise_parse_errors[:empty_values] || false
96
+ @_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
97
+ @_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
98
+ @_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
99
+ @_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
100
+ @_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
101
+ elsif @_raise_parse_errors == true
102
+ # Enable all error checks
103
+ @_check_empty_values = true
104
+ @_check_malformed_declarations = true
105
+ @_check_invalid_selectors = true
106
+ @_check_invalid_selector_syntax = true
107
+ @_check_malformed_at_rules = true
108
+ @_check_unclosed_blocks = true
109
+ else
110
+ # Disabled
111
+ @_check_empty_values = false
112
+ @_check_malformed_declarations = false
113
+ @_check_invalid_selectors = false
114
+ @_check_invalid_selector_syntax = false
115
+ @_check_malformed_at_rules = false
116
+ @_check_unclosed_blocks = false
117
+ end
144
118
 
145
- # Main parsing loop - char-by-char, NO REGEXP
146
- until eof?
147
- skip_ws_and_comments
148
- break if eof?
119
+ # Private: Internal counters
120
+ @_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
121
+ @_next_selector_list_id = 0 # Counter for selector list IDs
122
+ @_next_media_query_list_id = 0 # Counter for media query list IDs
123
+ @_rule_id_counter = 0 # Next rule ID (0-indexed)
124
+ @_media_query_count = 0 # Safety limit
125
+
126
+ # Public: Parser results (returned in parse result hash)
127
+ @rules = [] # Flat array of Rule structs
128
+ @media_queries = [] # Array of MediaQuery objects
129
+ @media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
130
+ @imports = [] # Array of ImportStatement structs
131
+ @charset = nil # @charset declaration
132
+
133
+ # Semi-private: Internal state exposed with _ prefix in result
134
+ @_selector_lists = {} # Hash: list_id => Array of rule IDs
135
+ @_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
136
+ @_has_nesting = false # Set to true if any nested rules found
137
+ end
138
+
139
+ def parse
140
+ # @import statements are now handled in parse_at_rule
141
+ # They must come before all rules (except @charset) per CSS spec
142
+
143
+ # Main parsing loop - char-by-char, NO REGEXP
144
+ until eof?
145
+ skip_ws_and_comments
146
+ break if eof?
147
+
148
+ # Peek at next byte to determine what to parse
149
+ byte = peek_byte
150
+
151
+ # Check for at-rules (@media, @charset, etc)
152
+ if byte == BYTE_AT
153
+ parse_at_rule
154
+ next
155
+ end
149
156
 
150
- # Peek at next byte to determine what to parse
151
- byte = peek_byte
157
+ # Must be a selector-based rule
158
+ selector = parse_selector
152
159
 
153
- # Check for at-rules (@media, @charset, etc)
154
- if byte == BYTE_AT
155
- parse_at_rule
156
- next
157
- end
160
+ if selector.nil? || selector.empty?
161
+ next
162
+ end
158
163
 
159
- # Must be a selector-based rule
160
- selector = parse_selector
164
+ # Find the block boundaries
165
+ decl_start = @_pos # Should be right after the {
166
+ decl_end = find_matching_brace(decl_start)
167
+
168
+ # Check if block has nested selectors
169
+ if has_nested_selectors?(decl_start, decl_end)
170
+ # NESTED PATH: Parse mixed declarations + nested rules
171
+ # Split comma-separated selectors and parse each one
172
+ selectors = split_and_validate_selectors(selector, decl_start)
173
+
174
+ selectors.each do |individual_selector|
175
+ next if individual_selector.empty?
176
+
177
+ # Get rule ID for this selector
178
+ current_rule_id = @_rule_id_counter
179
+ @_rule_id_counter += 1
180
+
181
+ # Reserve parent's position in rules array (ensures parent comes before nested)
182
+ parent_position = @rules.length
183
+ @rules << nil # Placeholder
184
+
185
+ # Parse mixed block (declarations + nested selectors)
186
+ @_depth += 1
187
+ parent_declarations = parse_mixed_block(decl_start, decl_end,
188
+ individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
189
+ @_depth -= 1
190
+
191
+ # Create parent rule and replace placeholder
192
+ rule = Rule.new(
193
+ current_rule_id,
194
+ individual_selector,
195
+ parent_declarations,
196
+ nil, # specificity
197
+ nil, # parent_rule_id (top-level)
198
+ nil # nesting_style
199
+ )
200
+
201
+ @rules[parent_position] = rule
202
+ end
161
203
 
162
- if selector.nil? || selector.empty?
163
- next
164
- end
204
+ # Move position past the closing brace
205
+ @_pos = decl_end
206
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
207
+ else
208
+ # NON-NESTED PATH: Parse declarations only
209
+ @_pos = decl_start # Reset to start of block
210
+ declarations = parse_declarations
211
+
212
+ # Split comma-separated selectors into individual rules
213
+ selectors = split_and_validate_selectors(selector, decl_start)
214
+
215
+ # Determine if we should track this as a selector list
216
+ # Check boolean first to potentially avoid size() call via short-circuit evaluation
217
+ list_id = nil
218
+ if @_selector_lists_enabled && selectors.size > 1
219
+ list_id = @_next_selector_list_id
220
+ @_next_selector_list_id += 1
221
+ @_selector_lists[list_id] = []
222
+ end
165
223
 
166
- # Find the block boundaries
167
- decl_start = @_pos # Should be right after the {
168
- decl_end = find_matching_brace(decl_start)
224
+ selectors.each do |individual_selector|
225
+ next if individual_selector.empty?
169
226
 
170
- # Check if block has nested selectors
171
- if has_nested_selectors?(decl_start, decl_end)
172
- # NESTED PATH: Parse mixed declarations + nested rules
173
- # Split comma-separated selectors and parse each one
174
- selectors = selector.split(',')
227
+ rule_id = @_rule_id_counter
175
228
 
176
- selectors.each do |individual_selector|
177
- individual_selector.strip!
229
+ # Dup declarations for each rule in a selector list to avoid shared state
230
+ # (principle of least surprise - modifying one rule shouldn't affect others)
231
+ # Must deep dup: both the array and the Declaration objects inside
232
+ rule_declarations = if list_id
233
+ declarations.map { |d| Declaration.new(d.property, d.value, d.important) }
234
+ else
235
+ declarations
236
+ end
178
237
 
179
- # Check for empty selector in comma-separated list
180
- if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
181
- raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
182
- css: @_css, pos: decl_start, type: :invalid_selector_syntax)
238
+ # Create Rule struct (with selector_list_id as 7th parameter)
239
+ rule = Rule.new(
240
+ rule_id, # id
241
+ individual_selector, # selector
242
+ rule_declarations, # declarations
243
+ nil, # specificity (calculated lazily)
244
+ nil, # parent_rule_id
245
+ nil, # nesting_style
246
+ list_id # selector_list_id
247
+ )
248
+
249
+ @rules << rule
250
+ @_rule_id_counter += 1
251
+
252
+ # Track in selector list if applicable
253
+ @_selector_lists[list_id] << rule_id if list_id
254
+ end
183
255
  end
184
-
185
- next if individual_selector.empty?
186
-
187
- # Get rule ID for this selector
188
- current_rule_id = @_rule_id_counter
189
- @_rule_id_counter += 1
190
-
191
- # Reserve parent's position in rules array (ensures parent comes before nested)
192
- parent_position = @rules.length
193
- @rules << nil # Placeholder
194
-
195
- # Parse mixed block (declarations + nested selectors)
196
- @_depth += 1
197
- parent_declarations = parse_mixed_block(decl_start, decl_end,
198
- individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
199
- @_depth -= 1
200
-
201
- # Create parent rule and replace placeholder
202
- rule = Rule.new(
203
- current_rule_id,
204
- individual_selector,
205
- parent_declarations,
206
- nil, # specificity
207
- nil, # parent_rule_id (top-level)
208
- nil # nesting_style
209
- )
210
-
211
- @rules[parent_position] = rule
212
256
  end
213
257
 
214
- # Move position past the closing brace
215
- @_pos = decl_end
216
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
217
- else
218
- # NON-NESTED PATH: Parse declarations only
219
- @_pos = decl_start # Reset to start of block
220
- declarations = parse_declarations
221
-
222
- # Split comma-separated selectors into individual rules
258
+ {
259
+ rules: @rules,
260
+ _media_index: @media_index,
261
+ media_queries: @media_queries,
262
+ _selector_lists: @_selector_lists,
263
+ _media_query_lists: @_media_query_lists,
264
+ imports: @imports,
265
+ charset: @charset,
266
+ _has_nesting: @_has_nesting
267
+ }
268
+ end
269
+
270
+ private
271
+
272
+ # Split a comma-separated selector list into individual selector strings,
273
+ # stripping whitespace from each and validating (in strict mode) that
274
+ # none are empty. Empty entries are left in the returned array - callers
275
+ # skip them - since the emptiness check needs the original
276
+ # comma-separated count (selectors.size > 1) to know whether this is
277
+ # really a list or just a single selector, which callers no longer have
278
+ # once they've filtered.
279
+ #
280
+ # @param selector [String] Raw selector text (already trimmed as a whole)
281
+ # @param pos [Integer] Position to report in a raised ParseError
282
+ # @return [Array<String>] Individual selectors, stripped
283
+ def split_and_validate_selectors(selector, pos)
223
284
  selectors = selector.split(',')
224
-
225
- # Determine if we should track this as a selector list
226
- # Check boolean first to potentially avoid size() call via short-circuit evaluation
227
- list_id = nil
228
- if @_selector_lists_enabled && selectors.size > 1
229
- list_id = @_next_selector_list_id
230
- @_next_selector_list_id += 1
231
- @_selector_lists[list_id] = []
232
- end
233
-
234
285
  selectors.each do |individual_selector|
235
286
  individual_selector.strip!
236
287
 
237
- # Check for empty selector in comma-separated list
238
288
  if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
239
289
  raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
240
- css: @_css, pos: decl_start, type: :invalid_selector_syntax)
290
+ css: @_css, pos: pos, type: :invalid_selector_syntax)
241
291
  end
242
-
243
- next if individual_selector.empty?
244
-
245
- rule_id = @_rule_id_counter
246
-
247
- # Dup declarations for each rule in a selector list to avoid shared state
248
- # (principle of least surprise - modifying one rule shouldn't affect others)
249
- # Must deep dup: both the array and the Declaration objects inside
250
- rule_declarations = if list_id
251
- declarations.map { |d| Declaration.new(d.property, d.value, d.important) }
252
- else
253
- declarations
254
- end
255
-
256
- # Create Rule struct (with selector_list_id as 7th parameter)
257
- rule = Rule.new(
258
- rule_id, # id
259
- individual_selector, # selector
260
- rule_declarations, # declarations
261
- nil, # specificity (calculated lazily)
262
- nil, # parent_rule_id
263
- nil, # nesting_style
264
- list_id # selector_list_id
265
- )
266
-
267
- @rules << rule
268
- @_rule_id_counter += 1
269
-
270
- # Track in selector list if applicable
271
- @_selector_lists[list_id] << rule_id if list_id
272
292
  end
293
+ selectors
273
294
  end
274
- end
275
-
276
- {
277
- rules: @rules,
278
- _media_index: @media_index,
279
- media_queries: @media_queries,
280
- _selector_lists: @_selector_lists,
281
- _media_query_lists: @_media_query_lists,
282
- imports: @imports,
283
- charset: @charset,
284
- _has_nesting: @_has_nesting
285
- }
286
- end
287
-
288
- private
289
-
290
- # Check if we're at end of input
291
- def eof?
292
- @_pos >= @_len
293
- end
294
-
295
- # Peek current byte without advancing
296
- # @return [Integer, nil] Byte value or nil if EOF
297
- def peek_byte
298
- return nil if eof?
299
-
300
- @_css.getbyte(@_pos)
301
- end
302
-
303
- # Delegate to module-level helper methods (now work with bytes)
304
- def whitespace?(byte)
305
- Cataract.is_whitespace?(byte)
306
- end
307
295
 
308
- def letter?(byte)
309
- Cataract.letter?(byte)
310
- end
311
-
312
- def digit?(byte)
313
- Cataract.digit?(byte)
314
- end
315
-
316
- def ident_char?(byte)
317
- Cataract.ident_char?(byte)
318
- end
319
-
320
- def skip_whitespace
321
- @_pos += 1 while !eof? && whitespace?(peek_byte)
322
- end
323
-
324
- def skip_comment # rubocop:disable Naming/PredicateMethod
325
- return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
326
-
327
- @_pos += 2 # Skip /*
328
- while @_pos + 1 < @_len
329
- if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
330
- @_pos += 2 # Skip */
331
- return true
296
+ # Check if we're at end of input
297
+ def eof?
298
+ @_pos >= @_len
332
299
  end
333
- @_pos += 1
334
- end
335
- true
336
- end
337
300
 
338
- # Skip whitespace and comments until no more progress can be made
339
- #
340
- # Optimization: Using `begin...end until` instead of `loop + break` reduces VM overhead:
341
- # - loop + break: 29 instructions with catch table for break/redo/next, uses throw/send
342
- # - begin...end until: 24 instructions, simple jump-based loop, no catch table
343
- # Benchmark shows 15-51% speedup depending on YJIT
344
- def skip_ws_and_comments
345
- begin
346
- old_pos = @_pos
347
- skip_whitespace
348
- skip_comment
349
- end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
350
- end
301
+ # Peek current byte without advancing
302
+ # @return [Integer, nil] Byte value or nil if EOF
303
+ def peek_byte
304
+ return nil if eof?
351
305
 
352
- # Check if a selector contains only valid CSS selector characters and sequences
353
- # Returns true if valid, false if invalid
354
- # Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
355
- def valid_selector_syntax?(selector_text)
356
- i = 0
357
- len = selector_text.bytesize
358
-
359
- while i < len
360
- byte = selector_text.getbyte(i)
361
-
362
- # Check for invalid character sequences
363
- if i + 1 < len
364
- next_byte = selector_text.getbyte(i + 1)
365
- # Double dot (..) is invalid
366
- return false if byte == BYTE_DOT && next_byte == BYTE_DOT
367
- # Double hash (##) is invalid
368
- return false if byte == BYTE_HASH && next_byte == BYTE_HASH
306
+ @_css.getbyte(@_pos)
369
307
  end
370
308
 
371
- # Alphanumeric
372
- if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
373
- i += 1
374
- next
309
+ # Check if a byte is whitespace (space, tab, newline, CR)
310
+ # @param byte [Integer] Byte value from String#getbyte
311
+ # @return [Boolean] true if whitespace
312
+ def whitespace?(byte)
313
+ byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
375
314
  end
376
315
 
377
- # Whitespace
378
- if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
379
- i += 1
380
- next
316
+ def skip_whitespace
317
+ @_pos += 1 while !eof? && whitespace?(peek_byte)
381
318
  end
382
319
 
383
- # Valid CSS selector special characters
384
- case byte
385
- when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
386
- BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
387
- BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
388
- BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
389
- BYTE_COMMA
390
- i += 1
391
- else
392
- # Invalid character found
393
- return false
394
- end
395
- end
320
+ def skip_comment # rubocop:disable Naming/PredicateMethod
321
+ return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
396
322
 
397
- true
398
- end
323
+ @_pos += 2 # Skip /*
324
+ while @_pos + 1 < @_len
325
+ if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
326
+ @_pos += 2 # Skip */
327
+ return true
328
+ end
329
+ @_pos += 1
330
+ end
331
+ true
332
+ end
333
+
334
+ # Skip whitespace and comments until no more progress can be made
335
+ #
336
+ # Optimization: Using `begin...end until` instead of `loop + break` reduces VM overhead:
337
+ # - loop + break: 29 instructions with catch table for break/redo/next, uses throw/send
338
+ # - begin...end until: 24 instructions, simple jump-based loop, no catch table
339
+ # Benchmark shows 15-51% speedup depending on YJIT
340
+ def skip_ws_and_comments
341
+ begin
342
+ old_pos = @_pos
343
+ skip_whitespace
344
+ skip_comment
345
+ end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
346
+ end
347
+
348
+ # Check if a selector contains only valid CSS selector characters and sequences
349
+ # Returns true if valid, false if invalid
350
+ # Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
351
+ def valid_selector_syntax?(selector_text)
352
+ i = 0
353
+ len = selector_text.bytesize
354
+
355
+ while i < len
356
+ byte = selector_text.getbyte(i)
357
+
358
+ # Check for invalid character sequences
359
+ if i + 1 < len
360
+ next_byte = selector_text.getbyte(i + 1)
361
+ # Double dot (..) is invalid
362
+ return false if byte == BYTE_DOT && next_byte == BYTE_DOT
363
+ # Double hash (##) is invalid
364
+ return false if byte == BYTE_HASH && next_byte == BYTE_HASH
365
+ end
399
366
 
400
- # Parse a single CSS declaration (property: value)
401
- #
402
- # Performance-critical helper that parses one declaration.
403
- # Shared by parse_mixed_block, parse_declarations, and parse_declarations_block.
404
- #
405
- # @param pos [Integer] Current position in CSS string
406
- # @param end_pos [Integer] End position (boundary for parsing)
407
- # @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
408
- # @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
409
- def parse_single_declaration(pos, end_pos, parse_important)
410
- # Parse property name (scan until ':')
411
- prop_start = pos
412
- while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
413
- @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
414
- pos += 1
415
- end
367
+ # Alphanumeric
368
+ if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
369
+ i += 1
370
+ next
371
+ end
416
372
 
417
- # Skip if malformed (no colon found)
418
- if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
419
- # Error recovery: skip to next semicolon
420
- while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
421
- pos += 1
422
- end
423
- pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
424
- return [nil, pos]
425
- end
373
+ # Whitespace
374
+ if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
375
+ i += 1
376
+ next
377
+ end
426
378
 
427
- # Trim trailing whitespace from property
428
- prop_end = pos
429
- while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
430
- prop_end -= 1
431
- end
379
+ # Valid CSS selector special characters
380
+ case byte
381
+ when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
382
+ BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
383
+ BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
384
+ BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
385
+ BYTE_COMMA
386
+ i += 1
387
+ else
388
+ # Invalid character found
389
+ return false
390
+ end
391
+ end
432
392
 
433
- # Extract and normalize property name
434
- property = byteslice_encoded(prop_start, prop_end - prop_start)
435
- # Custom properties (--foo) are case-sensitive and can contain Unicode
436
- # Regular properties are ASCII-only and case-insensitive
437
- unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
438
- property.force_encoding('US-ASCII')
439
- property.downcase!
440
- end
393
+ true
394
+ end
395
+
396
+ # Detect and strip a trailing '!important' marker from an already-extracted,
397
+ # already-right-trimmed declaration value, in place. Shared by
398
+ # parse_single_declaration and parse_declarations so both code paths agree
399
+ # on what counts as important.
400
+ #
401
+ # The CSS2.1 grammar defines the IMPORTANT_SYM lexical token as:
402
+ # "!"({w}|{comment})*{I}{M}{P}{O}{R}{T}{A}{N}{T}
403
+ # (https://www.w3.org/TR/CSS2/grammar.html) - i.e. zero or more whitespace
404
+ # tokens are allowed between '!' and 'important'.
405
+ #
406
+ # Mutates value in place (via slice!) instead of returning a new
407
+ # [value, important] tuple, so the common case (no !important present)
408
+ # allocates nothing beyond the two getbyte/compare checks below.
409
+ #
410
+ # @param value [String] already-extracted, right-trimmed declaration value (mutated in place)
411
+ # @return [Boolean] whether an important marker was found and stripped
412
+ def extract_important!(value) # rubocop:disable Naming/PredicateMethod
413
+ return false if value.bytesize < 10
441
414
 
442
- pos += 1 # Skip ':'
415
+ i = value.bytesize - 1
416
+ # Skip trailing whitespace
417
+ while i >= 0 && whitespace?(value.getbyte(i))
418
+ i -= 1
419
+ end
443
420
 
444
- # Skip leading whitespace in value
445
- while pos < end_pos && whitespace?(@_css.getbyte(pos))
446
- pos += 1
447
- end
421
+ # Check for 'important' (9 chars)
422
+ return false if i < 8 || value[(i - 8), 9] != 'important'
448
423
 
449
- # Parse value (scan until ';' or '}')
450
- val_start = pos
451
- while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
452
- pos += 1
453
- end
454
- val_end = pos
424
+ i -= 9
425
+ # Skip whitespace between '!' and 'important'
426
+ while i >= 0 && whitespace?(value.getbyte(i))
427
+ i -= 1
428
+ end
455
429
 
456
- # Trim trailing whitespace from value
457
- while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
458
- val_end -= 1
459
- end
430
+ # Check for '!'
431
+ return false unless i >= 0 && value.getbyte(i) == BYTE_BANG
432
+
433
+ # Remove everything from '!' onwards, in place
434
+ value.slice!(i..-1)
435
+ value.strip!
436
+ true
437
+ end
438
+
439
+ # Parse a single CSS declaration (property: value)
440
+ #
441
+ # Performance-critical helper that parses one declaration.
442
+ # Shared by parse_mixed_block and parse_declarations_block.
443
+ #
444
+ # @param pos [Integer] Current position in CSS string
445
+ # @param end_pos [Integer] End position (boundary for parsing)
446
+ # @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
447
+ # @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
448
+ def parse_single_declaration(pos, end_pos, parse_important)
449
+ # Parse property name (scan until ':').
450
+ # Also stops at '{' - a property name can never legitimately contain
451
+ # one, so its presence means this is actually an unsupported/invalid
452
+ # nested selector (e.g. a bare type selector without '&') that wasn't
453
+ # recognized as nesting. Treating it as malformed here (instead of
454
+ # scanning through the '{' looking for a colon) keeps its matching '}'
455
+ # from being silently swallowed later, which was corrupting output
456
+ # with unbalanced braces.
457
+ prop_start = pos
458
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
459
+ @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE &&
460
+ @_css.getbyte(pos) != BYTE_LBRACE
461
+ pos += 1
462
+ end
460
463
 
461
- value = byteslice_encoded(val_start, val_end - val_start)
464
+ # Skip if malformed (no colon found)
465
+ if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
466
+ # Error recovery: skip to next semicolon
467
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
468
+ pos += 1
469
+ end
470
+ pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
471
+ return [nil, pos]
472
+ end
462
473
 
463
- # Parse !important flag if requested
464
- important = false
465
- if parse_important && value.end_with?('!important')
466
- important = true
467
- # Remove '!important' and trailing whitespace
468
- value = value[0, value.length - 10].rstrip
469
- end
474
+ # Trim trailing whitespace from property
475
+ prop_end = pos
476
+ while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
477
+ prop_end -= 1
478
+ end
470
479
 
471
- # Skip semicolon if present
472
- pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
480
+ # Extract and normalize property name
481
+ property = byteslice_encoded(prop_start, prop_end - prop_start)
482
+ # Custom properties (--foo) are case-sensitive and can contain Unicode
483
+ # Regular properties are ASCII-only and case-insensitive
484
+ unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
485
+ property.force_encoding('US-ASCII')
486
+ property.downcase!
487
+ end
473
488
 
474
- # Return nil if empty declaration
475
- return [nil, pos] if prop_end <= prop_start || val_end <= val_start
489
+ pos += 1 # Skip ':'
476
490
 
477
- # Convert relative URLs to absolute if enabled
478
- value = convert_urls_in_value(value)
491
+ # Skip leading whitespace in value
492
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
493
+ pos += 1
494
+ end
479
495
 
480
- [Declaration.new(property, value, important), pos]
481
- end
496
+ # Parse value (scan until ';' outside parens, or '}'). Paren depth
497
+ # tracking keeps a ';' inside url(...)/rgba(...) from ending the value
498
+ # early - e.g. "url(data:image/svg+xml;base64,...)".
499
+ val_start = pos
500
+ paren_depth = 0
501
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_RBRACE
502
+ byte = @_css.getbyte(pos)
503
+ if byte == BYTE_LPAREN
504
+ paren_depth += 1
505
+ elsif byte == BYTE_RPAREN
506
+ paren_depth -= 1
507
+ elsif byte == BYTE_SEMICOLON && paren_depth == 0
508
+ break
509
+ end
510
+ pos += 1
511
+ end
512
+ val_end = pos
482
513
 
483
- # Find matching closing brace
484
- #
485
- # Performance notes (benchmarked on bootstrap.css with 2,400 braces):
486
- # - Using `return` instead of `break` avoids catch table overhead (~2% faster)
487
- # - Checking RBRACE before LBRACE is faster because closing braces are
488
- # encountered more frequently when searching forward from an opening brace
489
- # - Combined optimizations: baseline 666ms → optimized 652ms (2% improvement)
490
- #
491
- # Translated from C: see ext/cataract/css_parser.c find_matching_brace
492
- def find_matching_brace(start_pos)
493
- depth = 1
494
- pos = start_pos
495
-
496
- while pos < @_len
497
- byte = @_css.getbyte(pos)
498
- if byte == BYTE_RBRACE
499
- depth -= 1
500
- return pos if depth == 0
501
- elsif byte == BYTE_LBRACE
502
- depth += 1
503
- end
504
- pos += 1
505
- end
514
+ # Trim trailing whitespace from value
515
+ while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
516
+ val_end -= 1
517
+ end
506
518
 
507
- # Reached EOF without finding matching closing brace
508
- if @_check_unclosed_blocks && depth > 0
509
- raise ParseError.new('Unclosed block: missing closing brace',
510
- css: @_css, pos: start_pos - 1, type: :unclosed_block)
511
- end
519
+ value = byteslice_encoded(val_start, val_end - val_start)
512
520
 
513
- pos
514
- end
521
+ # Parse !important flag if requested
522
+ important = parse_important && extract_important!(value)
515
523
 
516
- # Parse selector (read until '{')
517
- def parse_selector
518
- start_pos = @_pos
524
+ # Skip semicolon if present
525
+ pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
519
526
 
520
- # Read until we find '{'
521
- until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
522
- @_pos += 1
523
- end
527
+ # Return nil if empty declaration
528
+ return [nil, pos] if prop_end <= prop_start || val_end <= val_start
524
529
 
525
- # If we hit EOF without finding '{', return nil
526
- return nil if eof?
530
+ # Convert relative URLs to absolute if enabled
531
+ value = convert_urls_in_value(value)
527
532
 
528
- # Extract selector text
529
- selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
533
+ [Declaration.new(property, value, important), pos]
534
+ end
530
535
 
531
- # Skip the '{'
532
- @_pos += 1 if peek_byte == BYTE_LBRACE
536
+ # Find matching closing brace
537
+ #
538
+ # Performance notes (benchmarked on bootstrap.css with 2,400 braces):
539
+ # - Using `return` instead of `break` avoids catch table overhead (~2% faster)
540
+ # - Checking RBRACE before LBRACE is faster because closing braces are
541
+ # encountered more frequently when searching forward from an opening brace
542
+ # - Combined optimizations: baseline 666ms → optimized 652ms (2% improvement)
543
+ #
544
+ # Translated from C: see ext/cataract/css_parser.c find_matching_brace
545
+ def find_matching_brace(start_pos)
546
+ depth = 1
547
+ pos = start_pos
548
+
549
+ while pos < @_len
550
+ byte = @_css.getbyte(pos)
551
+ if byte == BYTE_RBRACE
552
+ depth -= 1
553
+ return pos if depth == 0
554
+ elsif byte == BYTE_LBRACE
555
+ depth += 1
556
+ end
557
+ pos += 1
558
+ end
533
559
 
534
- # Trim whitespace from selector (in-place to avoid allocation)
535
- selector_text.strip!
560
+ # Reached EOF without finding matching closing brace
561
+ if @_check_unclosed_blocks && depth > 0
562
+ raise ParseError.new('Unclosed block: missing closing brace',
563
+ css: @_css, pos: start_pos - 1, type: :unclosed_block)
564
+ end
536
565
 
537
- # Validate selector (strict mode) - only if enabled to avoid overhead
538
- if @_check_invalid_selectors
539
- # Check for empty selector
540
- if selector_text.empty?
541
- raise ParseError.new('Invalid selector: empty selector',
542
- css: @_css, pos: start_pos, type: :invalid_selector)
566
+ pos
543
567
  end
544
568
 
545
- # Check if selector starts with a combinator (>, +, ~)
546
- first_char = selector_text.getbyte(0)
547
- if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
548
- raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
549
- css: @_css, pos: start_pos, type: :invalid_selector)
569
+ # Parse selector (read until '{')
570
+ # Advance @_pos to the next unescaped '{', or through to EOF if none is
571
+ # found. Doesn't consume the brace itself. Shared by parse_selector and
572
+ # scan_at_rule_selector, which both need this same scan but differ in
573
+ # what they do with the boundary afterward (validation, trimming, what
574
+ # to return on EOF).
575
+ #
576
+ # @return [Boolean] true if '{' was found, false if EOF was hit first
577
+ def skip_to_opening_brace # rubocop:disable Naming/PredicateMethod
578
+ until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
579
+ @_pos += 1
580
+ end
581
+ !eof?
550
582
  end
551
- end
552
583
 
553
- # Check selector syntax (whitelist validation for invalid characters/sequences)
554
- if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
555
- raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
556
- css: @_css, pos: start_pos, type: :invalid_selector_syntax)
557
- end
584
+ def parse_selector
585
+ start_pos = @_pos
558
586
 
559
- selector_text
560
- end
587
+ # If we hit EOF without finding '{', return nil
588
+ return nil unless skip_to_opening_brace
561
589
 
562
- # Parse mixed block containing declarations AND nested selectors/at-rules
563
- # Translated from C: see ext/cataract/css_parser.c parse_mixed_block
564
- # Returns: Array of declarations (only the declarations, not nested rules)
565
- def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
566
- # Check recursion depth to prevent stack overflow
567
- if @_depth > MAX_PARSE_DEPTH
568
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
569
- end
590
+ # Extract selector text
591
+ selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
570
592
 
571
- declarations = []
572
- pos = start_pos
593
+ # Skip the '{'
594
+ @_pos += 1 if peek_byte == BYTE_LBRACE
573
595
 
574
- while pos < end_pos
575
- # Skip whitespace and comments
576
- while pos < end_pos && whitespace?(@_css.getbyte(pos))
577
- pos += 1
578
- end
579
- break if pos >= end_pos
596
+ # Trim whitespace from selector (in-place to avoid allocation)
597
+ selector_text.strip!
580
598
 
581
- # Skip comments
582
- if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
583
- pos += 2
584
- while pos + 1 < end_pos
585
- if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
586
- pos += 2
587
- break
599
+ # Validate selector (strict mode) - only if enabled to avoid overhead
600
+ if @_check_invalid_selectors
601
+ # Check for empty selector
602
+ if selector_text.empty?
603
+ raise ParseError.new('Invalid selector: empty selector',
604
+ css: @_css, pos: start_pos, type: :invalid_selector)
588
605
  end
589
- pos += 1
590
- end
591
- next
592
- end
593
-
594
- # Check if this is a nested @media query
595
- if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
596
- byteslice_encoded(pos, 6) == '@media' &&
597
- (pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
598
- # Nested @media - parse with parent selector as context
599
- media_start = pos + 6
600
- while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
601
- media_start += 1
602
- end
603
606
 
604
- # Find opening brace
605
- media_query_end = media_start
606
- while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
607
- media_query_end += 1
607
+ # Check if selector starts with a combinator (>, +, ~)
608
+ first_char = selector_text.getbyte(0)
609
+ if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
610
+ raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
611
+ css: @_css, pos: start_pos, type: :invalid_selector)
612
+ end
608
613
  end
609
- break if media_query_end >= end_pos
610
614
 
611
- # Extract media query (trim trailing whitespace)
612
- media_query_end_trimmed = media_query_end
613
- while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
614
- media_query_end_trimmed -= 1
615
+ # Check selector syntax (whitelist validation for invalid characters/sequences)
616
+ if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
617
+ raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
618
+ css: @_css, pos: start_pos, type: :invalid_selector_syntax)
615
619
  end
616
- media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
617
- # Keep media query exactly as written - parentheses are required per CSS spec
618
- media_query_str.strip!
619
- media_sym = media_query_str.to_sym
620
-
621
- pos = media_query_end + 1 # Skip {
622
-
623
- # Find matching closing brace
624
- media_block_start = pos
625
- media_block_end = find_matching_brace(pos)
626
- pos = media_block_end
627
- pos += 1 if pos < end_pos # Skip }
628
-
629
- # Combine media queries: parent + child
630
- combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
631
-
632
- # Create MediaQuery object for this nested @media
633
- # If we're already in a media query context, combine with parent
634
- nested_media_query_id = if parent_media_query_id
635
- # Combine with parent MediaQuery
636
- parent_mq = @media_queries[parent_media_query_id]
637
-
638
- # This should never happen - parent_media_query_id should always be valid
639
- if parent_mq.nil?
640
- raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
641
- end
642
-
643
- # Combine parent media query with child
644
- _child_type, child_conditions = parse_media_query_parts(media_query_str)
645
- combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
646
- combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
647
- @media_queries << combined_mq
648
- combined_id = @_media_query_id_counter
649
- @_media_query_id_counter += 1
650
- combined_id
651
- else
652
- # No parent context, just use the child media query
653
- media_type, media_conditions = parse_media_query_parts(media_query_str)
654
- nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
655
- @media_queries << nested_media_query
656
- mq_id = @_media_query_id_counter
657
- @_media_query_id_counter += 1
658
- mq_id
659
- end
660
-
661
- # Create rule ID for this media rule
662
- media_rule_id = @_rule_id_counter
663
- @_rule_id_counter += 1
664
620
 
665
- # Reserve position in rules array (ensures sequential IDs match array indices)
666
- rule_position = @rules.length
667
- @rules << nil # Placeholder
668
-
669
- # Parse mixed block recursively with the nested media query ID as context
670
- @_depth += 1
671
- media_declarations = parse_mixed_block(media_block_start, media_block_end,
672
- parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
673
- @_depth -= 1
621
+ selector_text
622
+ end
674
623
 
675
- # Create rule with parent selector and declarations, associated with combined media query
676
- rule = Rule.new(
677
- media_rule_id,
678
- parent_selector,
679
- media_declarations,
680
- nil, # specificity
681
- parent_rule_id,
682
- nil, # nesting_style (nil for @media nesting)
683
- nil, # selector_list_id
684
- nested_media_query_id # media_query_id
685
- )
624
+ # Parse mixed block containing declarations AND nested selectors/at-rules
625
+ # Translated from C: see ext/cataract/css_parser.c parse_mixed_block
626
+ # Returns: Array of declarations (only the declarations, not nested rules)
627
+ def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
628
+ # Check recursion depth to prevent stack overflow
629
+ if @_depth > MAX_PARSE_DEPTH
630
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
631
+ end
686
632
 
687
- # Mark that we have nesting
688
- @_has_nesting = true unless parent_rule_id.nil?
633
+ declarations = []
634
+ pos = start_pos
689
635
 
690
- # Replace placeholder with actual rule
691
- @rules[rule_position] = rule
692
- next
693
- end
636
+ while pos < end_pos
637
+ # Skip whitespace and comments
638
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
639
+ pos += 1
640
+ end
641
+ break if pos >= end_pos
694
642
 
695
- # Check if this is a nested selector
696
- byte = @_css.getbyte(pos)
697
- if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
698
- byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
699
- byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
700
- # Find the opening brace
701
- nested_sel_start = pos
702
- while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
703
- pos += 1
704
- end
705
- break if pos >= end_pos
643
+ # Skip comments
644
+ if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
645
+ pos += 2
646
+ while pos + 1 < end_pos
647
+ if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
648
+ pos += 2
649
+ break
650
+ end
651
+ pos += 1
652
+ end
653
+ next
654
+ end
706
655
 
707
- nested_sel_end = pos
708
- # Trim trailing whitespace
709
- while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
710
- nested_sel_end -= 1
711
- end
656
+ # Check if this is a nested @media query
657
+ if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
658
+ byteslice_encoded(pos, 6) == '@media' &&
659
+ (pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
660
+ # Nested @media - parse with parent selector as context
661
+ media_start = pos + 6
662
+ while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
663
+ media_start += 1
664
+ end
712
665
 
713
- pos += 1 # Skip {
666
+ # Find opening brace
667
+ media_query_end = media_start
668
+ while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
669
+ media_query_end += 1
670
+ end
671
+ break if media_query_end >= end_pos
714
672
 
715
- # Find matching closing brace
716
- nested_block_start = pos
717
- nested_block_end = find_matching_brace(pos)
718
- pos = nested_block_end
719
- pos += 1 if pos < end_pos # Skip }
673
+ # Extract media query (trim trailing whitespace)
674
+ media_query_end_trimmed = media_query_end
675
+ while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
676
+ media_query_end_trimmed -= 1
677
+ end
678
+ media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
679
+ # Keep media query exactly as written - parentheses are required per CSS spec
680
+ media_query_str.strip!
681
+ media_sym = media_query_str.to_sym
682
+
683
+ pos = media_query_end + 1 # Skip {
684
+
685
+ # Find matching closing brace
686
+ media_block_start = pos
687
+ media_block_end = find_matching_brace(pos)
688
+ pos = media_block_end
689
+ pos += 1 if pos < end_pos # Skip }
690
+
691
+ # Combine media queries: parent + child
692
+ combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
693
+
694
+ # Create MediaQuery object for this nested @media
695
+ # If we're already in a media query context, combine with parent
696
+ nested_media_query_id = if parent_media_query_id
697
+ # Combine with parent MediaQuery
698
+ parent_mq = @media_queries[parent_media_query_id]
699
+
700
+ # This should never happen - parent_media_query_id should always be valid
701
+ if parent_mq.nil?
702
+ raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
703
+ end
704
+
705
+ # Combine parent media query with child
706
+ _child_type, child_conditions = parse_media_query_parts(media_query_str)
707
+ combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
708
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
709
+ @media_queries << combined_mq
710
+ combined_id = @_media_query_id_counter
711
+ @_media_query_id_counter += 1
712
+ combined_id
713
+ else
714
+ # No parent context, just use the child media query
715
+ media_type, media_conditions = parse_media_query_parts(media_query_str)
716
+ nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
717
+ @media_queries << nested_media_query
718
+ mq_id = @_media_query_id_counter
719
+ @_media_query_id_counter += 1
720
+ mq_id
721
+ end
722
+
723
+ # Create rule ID for this media rule
724
+ media_rule_id = @_rule_id_counter
725
+ @_rule_id_counter += 1
726
+
727
+ # Reserve position in rules array (ensures sequential IDs match array indices)
728
+ rule_position = @rules.length
729
+ @rules << nil # Placeholder
730
+
731
+ # Parse mixed block recursively with the nested media query ID as context
732
+ @_depth += 1
733
+ media_declarations = parse_mixed_block(media_block_start, media_block_end,
734
+ parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
735
+ @_depth -= 1
736
+
737
+ # Create rule with parent selector and declarations, associated with combined media query
738
+ rule = Rule.new(
739
+ media_rule_id,
740
+ parent_selector,
741
+ media_declarations,
742
+ nil, # specificity
743
+ parent_rule_id,
744
+ nil, # nesting_style (nil for @media nesting)
745
+ nil, # selector_list_id
746
+ nested_media_query_id # media_query_id
747
+ )
748
+
749
+ # Mark that we have nesting
750
+ @_has_nesting = true unless parent_rule_id.nil?
751
+
752
+ # Replace placeholder with actual rule
753
+ @rules[rule_position] = rule
754
+ next
755
+ end
720
756
 
721
- # Extract nested selector and split on commas
722
- nested_selector_text = byteslice_encoded(nested_sel_start, nested_sel_end - nested_sel_start)
723
- nested_selectors = nested_selector_text.split(',')
757
+ # Check if this is a nested selector
758
+ byte = @_css.getbyte(pos)
759
+ if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
760
+ byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
761
+ byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
762
+ # Find the opening brace
763
+ nested_sel_start = pos
764
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
765
+ pos += 1
766
+ end
767
+ break if pos >= end_pos
724
768
 
725
- nested_selectors.each do |seg|
726
- seg.strip!
727
- next if seg.empty?
769
+ nested_sel_end = pos
770
+ # Trim trailing whitespace
771
+ while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
772
+ nested_sel_end -= 1
773
+ end
728
774
 
729
- # Resolve nested selector
730
- resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
775
+ pos += 1 # Skip {
776
+
777
+ # Find matching closing brace
778
+ nested_block_start = pos
779
+ nested_block_end = find_matching_brace(pos)
780
+ pos = nested_block_end
781
+ pos += 1 if pos < end_pos # Skip }
782
+
783
+ # Extract nested selector and split on commas
784
+ nested_selector_text = byteslice_encoded(nested_sel_start, nested_sel_end - nested_sel_start)
785
+ nested_selectors = nested_selector_text.split(',')
786
+
787
+ nested_selectors.each do |seg|
788
+ seg.strip!
789
+ next if seg.empty?
790
+
791
+ # Resolve nested selector
792
+ resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
793
+
794
+ # Get rule ID
795
+ rule_id = @_rule_id_counter
796
+ @_rule_id_counter += 1
797
+
798
+ # Reserve position in rules array (ensures sequential IDs match array indices)
799
+ rule_position = @rules.length
800
+ @rules << nil # Placeholder
801
+
802
+ # Recursively parse nested block
803
+ @_depth += 1
804
+ nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
805
+ resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
806
+ @_depth -= 1
807
+
808
+ # Create rule for nested selector
809
+ rule = Rule.new(
810
+ rule_id,
811
+ resolved_selector,
812
+ nested_declarations,
813
+ nil, # specificity
814
+ parent_rule_id,
815
+ nesting_style
816
+ )
817
+
818
+ # Mark that we have nesting
819
+ @_has_nesting = true unless parent_rule_id.nil?
820
+
821
+ # Replace placeholder with actual rule
822
+ @rules[rule_position] = rule
823
+ end
731
824
 
732
- # Get rule ID
733
- rule_id = @_rule_id_counter
734
- @_rule_id_counter += 1
825
+ next
826
+ end
735
827
 
736
- # Reserve position in rules array (ensures sequential IDs match array indices)
737
- rule_position = @rules.length
738
- @rules << nil # Placeholder
739
-
740
- # Recursively parse nested block
741
- @_depth += 1
742
- nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
743
- resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
744
- @_depth -= 1
745
-
746
- # Create rule for nested selector
747
- rule = Rule.new(
748
- rule_id,
749
- resolved_selector,
750
- nested_declarations,
751
- nil, # specificity
752
- parent_rule_id,
753
- nesting_style
754
- )
755
-
756
- # Mark that we have nesting
757
- @_has_nesting = true unless parent_rule_id.nil?
758
-
759
- # Replace placeholder with actual rule
760
- @rules[rule_position] = rule
828
+ # This is a declaration - parse it using shared helper
829
+ decl, pos = parse_single_declaration(pos, end_pos, true)
830
+ declarations << decl if decl
761
831
  end
762
832
 
763
- next
833
+ declarations
764
834
  end
765
835
 
766
- # This is a declaration - parse it using shared helper
767
- decl, pos = parse_single_declaration(pos, end_pos, true)
768
- declarations << decl if decl
769
- end
836
+ # Parse declaration block (inside { ... })
837
+ # Assumes we're already past the opening '{'
838
+ def parse_declarations
839
+ declarations = []
770
840
 
771
- declarations
772
- end
841
+ # Read until we find the closing '}'
842
+ until eof?
843
+ skip_ws_and_comments
844
+ break if eof?
773
845
 
774
- # Parse declaration block (inside { ... })
775
- # Assumes we're already past the opening '{'
776
- def parse_declarations
777
- declarations = []
846
+ # Check for closing brace
847
+ if peek_byte == BYTE_RBRACE
848
+ @_pos += 1 # consume '}'
849
+ break
850
+ end
778
851
 
779
- # Read until we find the closing '}'
780
- until eof?
781
- skip_ws_and_comments
782
- break if eof?
852
+ # Parse property name (read until ':'). Also stops at '{' - a property
853
+ # name can never legitimately contain one, so its presence means this
854
+ # is actually an unsupported/invalid nested selector (e.g. a bare type
855
+ # selector without '&') that wasn't recognized as nesting. Treating it
856
+ # as malformed here (instead of scanning through the '{' looking for a
857
+ # colon) keeps its matching '}' from being silently swallowed later,
858
+ # which was corrupting output with unbalanced braces.
859
+ property_start = @_pos
860
+ until eof?
861
+ byte = peek_byte
862
+ break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE || byte == BYTE_LBRACE
783
863
 
784
- # Check for closing brace
785
- if peek_byte == BYTE_RBRACE
786
- @_pos += 1 # consume '}'
787
- break
788
- end
864
+ @_pos += 1
865
+ end
789
866
 
790
- # Parse property name (read until ':')
791
- property_start = @_pos
792
- until eof?
793
- byte = peek_byte
794
- break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
867
+ # Skip if no colon found (malformed)
868
+ if eof? || peek_byte != BYTE_COLON
869
+ # Check for malformed declaration (strict mode)
870
+ if @_check_malformed_declarations
871
+ property_text = byteslice_encoded(property_start, @_pos - property_start).strip
872
+ if property_text.empty?
873
+ raise ParseError.new('Malformed declaration: missing property name',
874
+ css: @_css, pos: property_start, type: :malformed_declaration)
875
+ else
876
+ raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
877
+ css: @_css, pos: property_start, type: :malformed_declaration)
878
+ end
879
+ end
795
880
 
796
- @_pos += 1
797
- end
881
+ # Try to recover by finding next ; or }
882
+ skip_to_semicolon_or_brace
883
+ next
884
+ end
798
885
 
799
- # Skip if no colon found (malformed)
800
- if eof? || peek_byte != BYTE_COLON
801
- # Check for malformed declaration (strict mode)
802
- if @_check_malformed_declarations
803
- property_text = byteslice_encoded(property_start, @_pos - property_start).strip
804
- if property_text.empty?
805
- raise ParseError.new('Malformed declaration: missing property name',
806
- css: @_css, pos: property_start, type: :malformed_declaration)
807
- else
808
- raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
809
- css: @_css, pos: property_start, type: :malformed_declaration)
886
+ # Extract property name - use UTF-8 encoding to support custom properties with Unicode
887
+ property = byteslice_encoded(property_start, @_pos - property_start)
888
+ property.strip!
889
+ # Custom properties (--foo) are case-sensitive and can contain Unicode
890
+ # Regular properties are ASCII-only and case-insensitive
891
+ unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
892
+ # Regular property: force ASCII encoding and downcase
893
+ property.force_encoding('US-ASCII')
894
+ property.downcase!
810
895
  end
811
- end
896
+ @_pos += 1 # skip ':'
897
+
898
+ skip_ws_and_comments
899
+
900
+ # Parse value (read until ';' outside parens, or '}', but respect
901
+ # quoted strings). Paren depth tracking keeps a ';' inside
902
+ # url(...)/rgba(...) from ending the value early - e.g.
903
+ # "url(data:image/svg+xml;base64,...)".
904
+ value_start = @_pos
905
+ in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
906
+ paren_depth = 0
907
+
908
+ until eof?
909
+ byte = peek_byte
910
+
911
+ if in_quote
912
+ # Inside quoted string - only exit on matching quote
913
+ if byte == in_quote
914
+ in_quote = nil
915
+ elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
916
+ # Skip escaped character
917
+ @_pos += 1
918
+ end
919
+ else
920
+ # Not in quote - check for terminators or quote/paren start
921
+ break if byte == BYTE_RBRACE || (byte == BYTE_SEMICOLON && paren_depth == 0)
922
+
923
+ # case/when compiles to opt_send(===) here, not opt_eq - benchmarked
924
+ # ~1.7x slower without YJIT, still slower with it.
925
+ if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE # rubocop:disable Style/CaseLikeIf
926
+ in_quote = byte
927
+ elsif byte == BYTE_LPAREN
928
+ paren_depth += 1
929
+ elsif byte == BYTE_RPAREN
930
+ paren_depth -= 1
931
+ end
932
+ end
812
933
 
813
- # Try to recover by finding next ; or }
814
- skip_to_semicolon_or_brace
815
- next
816
- end
934
+ @_pos += 1
935
+ end
817
936
 
818
- # Extract property name - use UTF-8 encoding to support custom properties with Unicode
819
- property = byteslice_encoded(property_start, @_pos - property_start)
820
- property.strip!
821
- # Custom properties (--foo) are case-sensitive and can contain Unicode
822
- # Regular properties are ASCII-only and case-insensitive
823
- unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
824
- # Regular property: force ASCII encoding and downcase
825
- property.force_encoding('US-ASCII')
826
- property.downcase!
827
- end
828
- @_pos += 1 # skip ':'
937
+ value = byteslice_encoded(value_start, @_pos - value_start)
938
+ value.strip!
829
939
 
830
- skip_ws_and_comments
940
+ # Check for !important (byte-by-byte, no regexp)
941
+ important = extract_important!(value)
831
942
 
832
- # Parse value (read until ';' or '}', but respect quoted strings)
833
- value_start = @_pos
834
- important = false
835
- in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
943
+ # Check for empty value (strict mode) - only if enabled to avoid overhead
944
+ if @_check_empty_values && value.empty?
945
+ raise ParseError.new("Empty value for property '#{property}'",
946
+ css: @_css, pos: property_start, type: :empty_value)
947
+ end
836
948
 
837
- until eof?
838
- byte = peek_byte
949
+ # Skip semicolon if present
950
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
839
951
 
840
- if in_quote
841
- # Inside quoted string - only exit on matching quote
842
- if byte == in_quote
843
- in_quote = nil
844
- elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
845
- # Skip escaped character
846
- @_pos += 1
847
- end
848
- else
849
- # Not in quote - check for terminators or quote start
850
- break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
952
+ # Convert relative URLs to absolute if enabled
953
+ value = convert_urls_in_value(value)
851
954
 
852
- if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE
853
- in_quote = byte
854
- end
955
+ # Create Declaration struct
956
+ declarations << Declaration.new(property, value, important)
855
957
  end
856
958
 
857
- @_pos += 1
959
+ declarations
858
960
  end
859
961
 
860
- value = byteslice_encoded(value_start, @_pos - value_start)
861
- value.strip!
862
-
863
- # Check for !important (byte-by-byte, no regexp)
864
- if value.bytesize >= 10
865
- # Scan backwards to find !important
866
- i = value.bytesize - 1
867
- # Skip trailing whitespace
868
- while i >= 0
869
- b = value.getbyte(i)
870
- break unless b == BYTE_SPACE || b == BYTE_TAB
962
+ # Scan from at_rule_start (pointing at the '@') to the at-rule's opening
963
+ # brace, returning the trimmed selector text (e.g. "@keyframes fade").
964
+ # Leaves @_pos just past the opening brace. Shared by at-rule kinds whose
965
+ # selector is just "everything up to '{'" with no separate condition/query
966
+ # text to extract along the way (@keyframes, @font-face, and the unknown/
967
+ # default at-rule fallback).
968
+ #
969
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
970
+ # @return [String, nil] The trimmed selector, or nil if '{' was never found
971
+ def scan_at_rule_selector(at_rule_start)
972
+ return nil unless skip_to_opening_brace
871
973
 
872
- i -= 1
974
+ selector_end = @_pos
975
+ while selector_end > at_rule_start && whitespace?(@_css.getbyte(selector_end - 1))
976
+ selector_end -= 1
873
977
  end
874
-
875
- # Check for 'important' (9 chars)
876
- if i >= 8 && value[(i - 8), 9] == 'important'
877
- i -= 9
878
- # Skip whitespace before 'important'
879
- while i >= 0
880
- b = value.getbyte(i)
881
- break unless b == BYTE_SPACE || b == BYTE_TAB
882
-
883
- i -= 1
978
+ selector = byteslice_encoded(at_rule_start, selector_end - at_rule_start)
979
+
980
+ @_pos += 1 # skip '{'
981
+ selector
982
+ end
983
+
984
+ # Merge selector_lists from a nested Parser's result into this parser,
985
+ # offsetting list ids and rule ids to avoid collisions. Shared by the
986
+ # conditional-group (@supports/@layer/@container/@scope) and @media
987
+ # at-rule handlers, which both recurse into a fresh Parser instance for
988
+ # their block content.
989
+ #
990
+ # @param nested_result [Hash] Result hash from a nested Parser#parse call
991
+ # @return [Integer] The list_id_offset used - pass through to
992
+ # merge_nested_rules so each rule's selector_list_id stays consistent
993
+ def merge_nested_selector_lists(nested_result)
994
+ list_id_offset = @_next_selector_list_id
995
+ if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
996
+ nested_result[:_selector_lists].each do |list_id, rule_ids|
997
+ new_list_id = list_id + list_id_offset
998
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
999
+ @_selector_lists[new_list_id] = offsetted_rule_ids
884
1000
  end
885
- # Check for '!'
886
- if i >= 0 && value.getbyte(i) == BYTE_BANG
887
- important = true
888
- # Remove everything from '!' onwards (use byteslice and strip in-place)
889
- value = value.byteslice(0, i)
890
- value.strip!
1001
+ @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
1002
+ end
1003
+ list_id_offset
1004
+ end
1005
+
1006
+ # Merge rules from a nested Parser's result into this parser's @rules,
1007
+ # renumbering ids and offsetting selector_list_id, then propagate whether
1008
+ # nesting was found inside the block up to this parser - the nested
1009
+ # parser tracked its own independent @_has_nesting, so without this a
1010
+ # rule nested inside a top-level at-rule block would carry a correct
1011
+ # parent_rule_id but the stylesheet would still report has_nesting:
1012
+ # false, causing serialization to use the flat, non-nesting-aware path
1013
+ # and print the rule's already-resolved selector as an unrelated
1014
+ # top-level rule instead of reconstructing the nested syntax.
1015
+ #
1016
+ # Yields each rule (after id/selector_list_id are updated, before it's
1017
+ # appended to @rules) for callers needing extra per-rule handling - e.g.
1018
+ # @media combining media_query_id with its outer media context.
1019
+ #
1020
+ # @param nested_result [Hash] Result hash from a nested Parser#parse call
1021
+ # @param list_id_offset [Integer] Offset from merge_nested_selector_lists
1022
+ def merge_nested_rules(nested_result, list_id_offset)
1023
+ nested_result[:rules].each do |rule|
1024
+ rule.id = @_rule_id_counter
1025
+ if rule.is_a?(Rule) && rule.selector_list_id
1026
+ rule.selector_list_id += list_id_offset
891
1027
  end
1028
+
1029
+ yield rule if block_given?
1030
+
1031
+ @_rule_id_counter += 1
1032
+ @rules << rule
892
1033
  end
893
- end
894
1034
 
895
- # Check for empty value (strict mode) - only if enabled to avoid overhead
896
- if @_check_empty_values && value.empty?
897
- raise ParseError.new("Empty value for property '#{property}'",
898
- css: @_css, pos: property_start, type: :empty_value)
1035
+ @_has_nesting ||= nested_result[:_has_nesting] # rubocop:disable Naming/MemoizedInstanceVariableName -- not memoization, propagating a flag from the nested parse
899
1036
  end
900
1037
 
901
- # Skip semicolon if present
902
- @_pos += 1 if peek_byte == BYTE_SEMICOLON
1038
+ # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
1039
+ # Translated from C: see ext/cataract/css_parser.c lines 962-1128
1040
+ def parse_at_rule
1041
+ at_rule_start = @_pos # Points to '@'
1042
+ @_pos += 1 # skip '@'
903
1043
 
904
- # Convert relative URLs to absolute if enabled
905
- value = convert_urls_in_value(value)
1044
+ # Find end of at-rule name (stop at whitespace or opening brace)
1045
+ name_start = @_pos
1046
+ until eof?
1047
+ byte = peek_byte
1048
+ break if whitespace?(byte) || byte == BYTE_LBRACE
906
1049
 
907
- # Create Declaration struct
908
- declarations << Declaration.new(property, value, important)
909
- end
1050
+ @_pos += 1
1051
+ end
910
1052
 
911
- declarations
912
- end
1053
+ at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
913
1054
 
914
- # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
915
- # Translated from C: see ext/cataract/css_parser.c lines 962-1128
916
- def parse_at_rule
917
- at_rule_start = @_pos # Points to '@'
918
- @_pos += 1 # skip '@'
1055
+ # Handle @charset specially - it's just @charset "value";
1056
+ return parse_charset_at_rule if at_rule_name == 'charset'
919
1057
 
920
- # Find end of at-rule name (stop at whitespace or opening brace)
921
- name_start = @_pos
922
- until eof?
923
- byte = peek_byte
924
- break if whitespace?(byte) || byte == BYTE_LBRACE
1058
+ # Handle @import - must come before rules (except @charset)
1059
+ return parse_import_at_rule if at_rule_name == 'import'
925
1060
 
926
- @_pos += 1
927
- end
1061
+ # Handle conditional group at-rules: @supports, @layer, @container, @scope
1062
+ # These behave like @media but don't affect media context
1063
+ return parse_conditional_group_at_rule(at_rule_name) if AT_RULE_TYPES.include?(at_rule_name)
928
1064
 
929
- at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
1065
+ # Handle @media specially - parse content and track in media_index
1066
+ return parse_media_at_rule if at_rule_name == 'media'
930
1067
 
931
- # Handle @charset specially - it's just @charset "value";
932
- if at_rule_name == 'charset'
933
- skip_ws_and_comments
934
- # Read until semicolon
935
- value_start = @_pos
936
- while !eof? && peek_byte != BYTE_SEMICOLON
937
- @_pos += 1
938
- end
1068
+ # Check for @keyframes (contains <rule-list>)
1069
+ is_keyframes = at_rule_name == 'keyframes' ||
1070
+ at_rule_name == '-webkit-keyframes' ||
1071
+ at_rule_name == '-moz-keyframes'
1072
+ return parse_keyframes_at_rule(at_rule_start) if is_keyframes
939
1073
 
940
- charset_value = byteslice_encoded(value_start, @_pos - value_start)
941
- charset_value.strip!
942
- # Remove quotes
943
- @charset = charset_value.delete('"\'')
1074
+ # Check for @font-face (contains <declaration-list>)
1075
+ return parse_font_face_at_rule(at_rule_start) if at_rule_name == 'font-face'
944
1076
 
945
- @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
946
- return
947
- end
1077
+ # Unknown at-rule (@property, @page, @counter-style, etc.)
1078
+ # Treat as a regular selector-based rule with declarations
1079
+ parse_unknown_at_rule(at_rule_start)
1080
+ end
948
1081
 
949
- # Handle @import - must come before rules (except @charset)
950
- if at_rule_name == 'import'
951
- # If we've already seen a rule, this @import is invalid
952
- if @rules.size > 0
953
- warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
954
- # Skip to semicolon
1082
+ # @charset "value"; - stores the value and consumes through the semicolon.
1083
+ def parse_charset_at_rule
1084
+ skip_ws_and_comments
1085
+ # Read until semicolon
1086
+ value_start = @_pos
955
1087
  while !eof? && peek_byte != BYTE_SEMICOLON
956
1088
  @_pos += 1
957
1089
  end
958
- @_pos += 1 if peek_byte == BYTE_SEMICOLON
959
- return
960
- end
961
-
962
- parse_import_statement
963
- return
964
- end
965
-
966
- # Handle conditional group at-rules: @supports, @layer, @container, @scope
967
- # These behave like @media but don't affect media context
968
- if AT_RULE_TYPES.include?(at_rule_name)
969
- skip_ws_and_comments
970
1090
 
971
- # Remember start of condition for error reporting
972
- condition_start = @_pos
1091
+ charset_value = byteslice_encoded(value_start, @_pos - value_start)
1092
+ charset_value.strip!
1093
+ # Remove quotes
1094
+ @charset = charset_value.delete('"\'')
973
1095
 
974
- # Skip to opening brace
975
- condition_end = @_pos
976
- while !eof? && peek_byte != BYTE_LBRACE
977
- condition_end = @_pos
978
- @_pos += 1
1096
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
979
1097
  end
980
1098
 
981
- return if eof? || peek_byte != BYTE_LBRACE
982
-
983
- # Validate condition (strict mode) - @supports, @container, @scope require conditions
984
- if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
985
- condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
986
- if condition_str.empty?
987
- raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
988
- css: @_css, pos: condition_start, type: :malformed_at_rule)
1099
+ # @import must appear before all rules (except @charset) per CSS spec -
1100
+ # anything else is invalid and gets skipped with a warning instead of
1101
+ # being parsed as an import.
1102
+ def parse_import_at_rule
1103
+ # If we've already seen a rule, this @import is invalid
1104
+ if @rules.size > 0
1105
+ warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
1106
+ # Skip to semicolon
1107
+ while !eof? && peek_byte != BYTE_SEMICOLON
1108
+ @_pos += 1
1109
+ end
1110
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
1111
+ return
989
1112
  end
990
- end
991
1113
 
992
- @_pos += 1 # skip '{'
1114
+ parse_import_statement
1115
+ end
993
1116
 
994
- # Find matching closing brace
995
- block_start = @_pos
996
- block_end = find_matching_brace(@_pos)
1117
+ # @supports, @layer, @container, @scope - behave like @media but don't
1118
+ # affect media context, so unlike parse_media_at_rule there's no media
1119
+ # query/media_query_id merging to do on top of the shared nested-parser
1120
+ # result merge.
1121
+ #
1122
+ # @param at_rule_name [String] The at-rule name (e.g. "supports"), used
1123
+ # to decide whether a condition is required and for error messages
1124
+ def parse_conditional_group_at_rule(at_rule_name)
1125
+ skip_ws_and_comments
997
1126
 
998
- # Check depth before recursing
999
- if @_depth + 1 > MAX_PARSE_DEPTH
1000
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1001
- end
1127
+ # Remember start of condition for error reporting
1128
+ condition_start = @_pos
1002
1129
 
1003
- # Recursively parse block content (preserve parent media context)
1004
- nested_parser = Parser.new(
1005
- byteslice_encoded(block_start, block_end - block_start),
1006
- parser_options: @_parser_options,
1007
- parent_media_sym: @_parent_media_sym,
1008
- depth: @_depth + 1
1009
- )
1010
-
1011
- nested_result = nested_parser.parse
1012
-
1013
- # Merge nested selector_lists with offsetted IDs
1014
- list_id_offset = @_next_selector_list_id
1015
- if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
1016
- nested_result[:_selector_lists].each do |list_id, rule_ids|
1017
- new_list_id = list_id + list_id_offset
1018
- offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
1019
- @_selector_lists[new_list_id] = offsetted_rule_ids
1130
+ # Skip to opening brace
1131
+ condition_end = @_pos
1132
+ while !eof? && peek_byte != BYTE_LBRACE
1133
+ condition_end = @_pos
1134
+ @_pos += 1
1020
1135
  end
1021
- @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
1022
- end
1023
1136
 
1024
- # NOTE: We no longer build media_index during parse
1025
- # It will be built from MediaQuery objects after import resolution
1137
+ return if eof? || peek_byte != BYTE_LBRACE
1026
1138
 
1027
- # Add nested rules to main rules array
1028
- nested_result[:rules].each do |rule|
1029
- rule.id = @_rule_id_counter
1030
- # Update selector_list_id if applicable
1031
- if rule.is_a?(Rule) && rule.selector_list_id
1032
- rule.selector_list_id += list_id_offset
1139
+ # Validate condition (strict mode) - @supports, @container, @scope require conditions
1140
+ if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
1141
+ condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
1142
+ if condition_str.empty?
1143
+ raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
1144
+ css: @_css, pos: condition_start, type: :malformed_at_rule)
1145
+ end
1033
1146
  end
1034
- @_rule_id_counter += 1
1035
- @rules << rule
1036
- end
1037
1147
 
1038
- # Move position past the closing brace
1039
- @_pos = block_end
1040
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1148
+ @_pos += 1 # skip '{'
1041
1149
 
1042
- return
1043
- end
1044
-
1045
- # Handle @media specially - parse content and track in media_index
1046
- if at_rule_name == 'media'
1047
- skip_ws_and_comments
1150
+ # Find matching closing brace
1151
+ block_start = @_pos
1152
+ block_end = find_matching_brace(@_pos)
1048
1153
 
1049
- # Find media query (up to opening brace)
1050
- mq_start = @_pos
1051
- while !eof? && peek_byte != BYTE_LBRACE
1052
- @_pos += 1
1053
- end
1154
+ # Check depth before recursing
1155
+ if @_depth + 1 > MAX_PARSE_DEPTH
1156
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1157
+ end
1054
1158
 
1055
- return if eof? || peek_byte != BYTE_LBRACE
1159
+ # Recursively parse block content (preserve parent media context)
1160
+ nested_parser = Parser.new(
1161
+ byteslice_encoded(block_start, block_end - block_start),
1162
+ parser_options: @_parser_options,
1163
+ parent_media_sym: @_parent_media_sym,
1164
+ depth: @_depth + 1
1165
+ )
1056
1166
 
1057
- mq_end = @_pos
1058
- # Trim trailing whitespace
1059
- while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
1060
- mq_end -= 1
1061
- end
1167
+ nested_result = nested_parser.parse
1062
1168
 
1063
- child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
1064
- # Keep media query exactly as written - parentheses are required per CSS spec
1065
- child_media_string.strip!
1169
+ # NOTE: We no longer build media_index during parse
1170
+ # It will be built from MediaQuery objects after import resolution
1171
+ list_id_offset = merge_nested_selector_lists(nested_result)
1172
+ merge_nested_rules(nested_result, list_id_offset)
1066
1173
 
1067
- # Validate @media has a query (strict mode)
1068
- if @_check_malformed_at_rules && child_media_string.empty?
1069
- raise ParseError.new('Malformed @media: missing media query or condition',
1070
- css: @_css, pos: mq_start, type: :malformed_at_rule)
1174
+ # Move position past the closing brace
1175
+ @_pos = block_end
1176
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1071
1177
  end
1072
1178
 
1073
- child_media_sym = child_media_string.to_sym
1179
+ # @media - parses the media query, recurses into a nested Parser for the
1180
+ # block content (combining its media context with any parent), and
1181
+ # merges the result. Unlike parse_conditional_group_at_rule, this also
1182
+ # merges MediaQuery objects/lists and combines each nested rule's
1183
+ # media_query_id with this block's own media context.
1184
+ def parse_media_at_rule
1185
+ skip_ws_and_comments
1074
1186
 
1075
- # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1076
- # Per W3C spec, comma acts as logical OR - each query is independent
1077
- media_query_strings = child_media_string.split(',').map(&:strip)
1187
+ # Find media query (up to opening brace)
1188
+ mq_start = @_pos
1189
+ return unless skip_to_opening_brace
1078
1190
 
1079
- # Create MediaQuery objects for each query in the list
1080
- media_query_ids = []
1081
- media_query_strings.each do |query_string|
1082
- media_type, media_conditions = parse_media_query_parts(query_string)
1083
- media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1084
- @media_queries << media_query
1085
- media_query_ids << @_media_query_id_counter
1086
- @_media_query_id_counter += 1
1087
- end
1191
+ mq_end = @_pos
1192
+ # Trim trailing whitespace
1193
+ while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
1194
+ mq_end -= 1
1195
+ end
1088
1196
 
1089
- # If multiple queries, track them as a list for serialization
1090
- if media_query_ids.size > 1
1091
- @_media_query_lists[@_next_media_query_list_id] = media_query_ids
1092
- @_next_media_query_list_id += 1
1093
- end
1197
+ child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
1198
+ # Keep media query exactly as written - parentheses are required per CSS spec
1199
+ child_media_string.strip!
1094
1200
 
1095
- # Use first query ID as the primary one for rules in this block
1096
- current_media_query_id = media_query_ids.first
1201
+ # Validate @media has a query (strict mode)
1202
+ if @_check_malformed_at_rules && child_media_string.empty?
1203
+ raise ParseError.new('Malformed @media: missing media query or condition',
1204
+ css: @_css, pos: mq_start, type: :malformed_at_rule)
1205
+ end
1206
+
1207
+ child_media_sym = child_media_string.to_sym
1097
1208
 
1098
- # Combine with parent media context
1099
- combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1209
+ # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1210
+ # Per W3C spec, comma acts as logical OR - each query is independent
1211
+ media_query_strings = child_media_string.split(',').map(&:strip)
1100
1212
 
1101
- # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
1102
- # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
1103
- # MediaQuery combining for nested @media happens in parse_mixed_block instead.
1104
- # So this is just an alias to current_media_query_id.
1105
- combined_media_query_id = current_media_query_id
1213
+ # Create MediaQuery objects for each query in the list
1214
+ media_query_ids = []
1215
+ media_query_strings.each do |query_string|
1216
+ media_type, media_conditions = parse_media_query_parts(query_string)
1217
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1218
+ @media_queries << media_query
1219
+ media_query_ids << @_media_query_id_counter
1220
+ @_media_query_id_counter += 1
1221
+ end
1106
1222
 
1107
- # Check media query limit
1108
- unless @media_index.key?(combined_media_sym)
1109
- @_media_query_count += 1
1110
- if @_media_query_count > MAX_MEDIA_QUERIES
1111
- raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
1223
+ # If multiple queries, track them as a list for serialization
1224
+ if media_query_ids.size > 1
1225
+ @_media_query_lists[@_next_media_query_list_id] = media_query_ids
1226
+ @_next_media_query_list_id += 1
1112
1227
  end
1113
- end
1114
1228
 
1115
- @_pos += 1 # skip '{'
1229
+ # Use first query ID as the primary one for rules in this block
1230
+ current_media_query_id = media_query_ids.first
1116
1231
 
1117
- # Find matching closing brace
1118
- block_start = @_pos
1119
- block_end = find_matching_brace(@_pos)
1232
+ # Combine with parent media context
1233
+ combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1120
1234
 
1121
- # Check depth before recursing
1122
- if @_depth + 1 > MAX_PARSE_DEPTH
1123
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1124
- end
1235
+ # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
1236
+ # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
1237
+ # MediaQuery combining for nested @media happens in parse_mixed_block instead.
1238
+ # So this is just an alias to current_media_query_id.
1239
+ combined_media_query_id = current_media_query_id
1125
1240
 
1126
- # Parse the content with the combined media context
1127
- # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
1128
- # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
1129
- nested_parser = Parser.new(
1130
- byteslice_encoded(block_start, block_end - block_start),
1131
- parser_options: @_parser_options,
1132
- parent_media_sym: combined_media_sym,
1133
- depth: @_depth + 1
1134
- )
1135
-
1136
- nested_result = nested_parser.parse
1137
-
1138
- # Merge nested selector_lists with offsetted IDs
1139
- list_id_offset = @_next_selector_list_id
1140
- if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
1141
- nested_result[:_selector_lists].each do |list_id, rule_ids|
1142
- new_list_id = list_id + list_id_offset
1143
- offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
1144
- @_selector_lists[new_list_id] = offsetted_rule_ids
1241
+ # Check media query limit
1242
+ unless @media_index.key?(combined_media_sym)
1243
+ @_media_query_count += 1
1244
+ if @_media_query_count > MAX_MEDIA_QUERIES
1245
+ raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
1246
+ end
1145
1247
  end
1146
- @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
1147
- end
1148
1248
 
1149
- # Merge nested MediaQuery objects with offsetted IDs
1150
- mq_id_offset = @_media_query_id_counter
1151
- if nested_result[:media_queries] && !nested_result[:media_queries].empty?
1152
- nested_result[:media_queries].each do |mq|
1153
- # Create new MediaQuery with offsetted ID
1154
- new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
1155
- @media_queries << new_mq
1156
- end
1157
- @_media_query_id_counter += nested_result[:media_queries].size
1158
- end
1249
+ @_pos += 1 # skip '{'
1250
+
1251
+ # Find matching closing brace
1252
+ block_start = @_pos
1253
+ block_end = find_matching_brace(@_pos)
1159
1254
 
1160
- # Merge nested media_query_lists with offsetted IDs
1161
- if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1162
- nested_result[:_media_query_lists].each do |list_id, mq_ids|
1163
- # Offset the list_id and media_query_ids
1164
- new_list_id = list_id + @_next_media_query_list_id
1165
- offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1166
- @_media_query_lists[new_list_id] = offsetted_mq_ids
1255
+ # Check depth before recursing
1256
+ if @_depth + 1 > MAX_PARSE_DEPTH
1257
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1167
1258
  end
1168
- @_next_media_query_list_id += nested_result[:_media_query_lists].size
1169
- end
1170
1259
 
1171
- # Merge nested media_index into ours (for nested @media)
1172
- # Note: We no longer build media_index during parse
1173
- # It will be built from MediaQuery objects after import resolution
1260
+ # Parse the content with the combined media context
1261
+ # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
1262
+ # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
1263
+ nested_parser = Parser.new(
1264
+ byteslice_encoded(block_start, block_end - block_start),
1265
+ parser_options: @_parser_options,
1266
+ parent_media_sym: combined_media_sym,
1267
+ depth: @_depth + 1
1268
+ )
1269
+
1270
+ nested_result = nested_parser.parse
1271
+
1272
+ list_id_offset = merge_nested_selector_lists(nested_result)
1273
+
1274
+ # Merge nested MediaQuery objects with offsetted IDs
1275
+ mq_id_offset = @_media_query_id_counter
1276
+ if nested_result[:media_queries] && !nested_result[:media_queries].empty?
1277
+ nested_result[:media_queries].each do |mq|
1278
+ # Create new MediaQuery with offsetted ID
1279
+ new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
1280
+ @media_queries << new_mq
1281
+ end
1282
+ @_media_query_id_counter += nested_result[:media_queries].size
1283
+ end
1174
1284
 
1175
- # Add nested rules to main rules array
1176
- nested_result[:rules].each do |rule|
1177
- rule.id = @_rule_id_counter
1178
- # Update selector_list_id if applicable
1179
- if rule.is_a?(Rule) && rule.selector_list_id
1180
- rule.selector_list_id += list_id_offset
1285
+ # Merge nested media_query_lists with offsetted IDs
1286
+ if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1287
+ nested_result[:_media_query_lists].each do |list_id, mq_ids|
1288
+ # Offset the list_id and media_query_ids
1289
+ new_list_id = list_id + @_next_media_query_list_id
1290
+ offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1291
+ @_media_query_lists[new_list_id] = offsetted_mq_ids
1292
+ end
1293
+ @_next_media_query_list_id += nested_result[:_media_query_lists].size
1181
1294
  end
1182
1295
 
1183
- # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1184
- if rule.media_query_id
1185
- # Nested parser assigned a media_query_id - need to combine with our context
1186
- nested_mq_id = rule.media_query_id + mq_id_offset
1187
- nested_mq = @media_queries[nested_mq_id]
1188
-
1189
- # Combine nested media query with our media context
1190
- if nested_mq && combined_media_query_id
1191
- outer_mq = @media_queries[combined_media_query_id]
1192
- if outer_mq
1193
- # Combine media queries directly without string building
1194
- combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
1195
- combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
1196
- @media_queries << combined_mq
1197
- rule.media_query_id = @_media_query_id_counter
1198
- @_media_query_id_counter += 1
1296
+ # Merge nested media_index into ours (for nested @media)
1297
+ # Note: We no longer build media_index during parse
1298
+ # It will be built from MediaQuery objects after import resolution
1299
+
1300
+ merge_nested_rules(nested_result, list_id_offset) do |rule|
1301
+ # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1302
+ if rule.media_query_id
1303
+ # Nested parser assigned a media_query_id - need to combine with our context
1304
+ nested_mq_id = rule.media_query_id + mq_id_offset
1305
+ nested_mq = @media_queries[nested_mq_id]
1306
+
1307
+ # Combine nested media query with our media context
1308
+ if nested_mq && combined_media_query_id
1309
+ outer_mq = @media_queries[combined_media_query_id]
1310
+ if outer_mq
1311
+ # Combine media queries directly without string building
1312
+ combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
1313
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
1314
+ @media_queries << combined_mq
1315
+ rule.media_query_id = @_media_query_id_counter
1316
+ @_media_query_id_counter += 1
1317
+ else
1318
+ rule.media_query_id = nested_mq_id
1319
+ end
1199
1320
  else
1200
1321
  rule.media_query_id = nested_mq_id
1201
1322
  end
1202
- else
1203
- rule.media_query_id = nested_mq_id
1323
+ elsif rule.respond_to?(:media_query_id=)
1324
+ # Assign the combined media_query_id if no media_query_id set
1325
+ # (applies to both Rule and AtRule)
1326
+ rule.media_query_id = combined_media_query_id
1204
1327
  end
1205
- elsif rule.respond_to?(:media_query_id=)
1206
- # Assign the combined media_query_id if no media_query_id set
1207
- # (applies to both Rule and AtRule)
1208
- rule.media_query_id = combined_media_query_id
1209
1328
  end
1210
1329
 
1211
- # NOTE: We no longer build media_index during parse
1212
- # It will be built from MediaQuery objects after import resolution
1213
-
1214
- @_rule_id_counter += 1
1215
- @rules << rule
1330
+ # Move position past the closing brace
1331
+ @_pos = block_end
1332
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1216
1333
  end
1217
1334
 
1218
- # Move position past the closing brace
1219
- @_pos = block_end
1220
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1335
+ # @keyframes (contains a <rule-list> of percentage/from/to blocks)
1336
+ #
1337
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1338
+ def parse_keyframes_at_rule(at_rule_start)
1339
+ # Build full selector string: "@keyframes fade"
1340
+ selector = scan_at_rule_selector(at_rule_start)
1341
+ return if selector.nil?
1221
1342
 
1222
- return
1223
- end
1343
+ # Find matching closing brace
1344
+ block_start = @_pos
1345
+ block_end = find_matching_brace(@_pos)
1224
1346
 
1225
- # Check for @keyframes (contains <rule-list>)
1226
- is_keyframes = at_rule_name == 'keyframes' ||
1227
- at_rule_name == '-webkit-keyframes' ||
1228
- at_rule_name == '-moz-keyframes'
1347
+ # Check depth before recursing
1348
+ if @_depth + 1 > MAX_PARSE_DEPTH
1349
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1350
+ end
1229
1351
 
1230
- if is_keyframes
1231
- # Build full selector string: "@keyframes fade"
1232
- selector_start = at_rule_start # Points to '@'
1352
+ # Parse keyframe blocks as rules (0%/from/to etc)
1353
+ # Create a nested parser context
1354
+ nested_parser = Parser.new(
1355
+ byteslice_encoded(block_start, block_end - block_start),
1356
+ parser_options: @_parser_options,
1357
+ depth: @_depth + 1
1358
+ )
1359
+ nested_result = nested_parser.parse
1360
+ content = nested_result[:rules]
1233
1361
 
1234
- # Skip to opening brace
1235
- while !eof? && peek_byte != BYTE_LBRACE
1236
- @_pos += 1
1237
- end
1362
+ # Move position past the closing brace
1363
+ @_pos = block_end
1364
+ # The closing brace should be at block_end
1365
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1238
1366
 
1239
- return if eof? || peek_byte != BYTE_LBRACE
1367
+ # Get rule ID and increment
1368
+ rule_id = @_rule_id_counter
1369
+ @_rule_id_counter += 1
1240
1370
 
1241
- selector_end = @_pos
1242
- # Trim trailing whitespace
1243
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1244
- selector_end -= 1
1371
+ # Create AtRule with nested rules
1372
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1373
+ @rules << at_rule
1245
1374
  end
1246
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1247
1375
 
1248
- @_pos += 1 # skip '{'
1376
+ # @font-face (contains a <declaration-list>)
1377
+ #
1378
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1379
+ def parse_font_face_at_rule(at_rule_start)
1380
+ # Build selector string: "@font-face"
1381
+ selector = scan_at_rule_selector(at_rule_start)
1382
+ return if selector.nil?
1249
1383
 
1250
- # Find matching closing brace
1251
- block_start = @_pos
1252
- block_end = find_matching_brace(@_pos)
1384
+ # Find matching closing brace
1385
+ decl_start = @_pos
1386
+ decl_end = find_matching_brace(@_pos)
1253
1387
 
1254
- # Check depth before recursing
1255
- if @_depth + 1 > MAX_PARSE_DEPTH
1256
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1257
- end
1388
+ # Parse declarations
1389
+ content = parse_declarations_block(decl_start, decl_end)
1258
1390
 
1259
- # Parse keyframe blocks as rules (0%/from/to etc)
1260
- # Create a nested parser context
1261
- nested_parser = Parser.new(
1262
- byteslice_encoded(block_start, block_end - block_start),
1263
- parser_options: @_parser_options,
1264
- depth: @_depth + 1
1265
- )
1266
- nested_result = nested_parser.parse
1267
- content = nested_result[:rules]
1268
-
1269
- # Move position past the closing brace
1270
- @_pos = block_end
1271
- # The closing brace should be at block_end
1272
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1273
-
1274
- # Get rule ID and increment
1275
- rule_id = @_rule_id_counter
1276
- @_rule_id_counter += 1
1277
-
1278
- # Create AtRule with nested rules
1279
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1280
- @rules << at_rule
1281
-
1282
- return
1283
- end
1391
+ # Move position past the closing brace
1392
+ @_pos = decl_end
1393
+ # The closing brace should be at decl_end
1394
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1284
1395
 
1285
- # Check for @font-face (contains <declaration-list>)
1286
- if at_rule_name == 'font-face'
1287
- # Build selector string: "@font-face"
1288
- selector_start = at_rule_start # Points to '@'
1396
+ # Get rule ID and increment
1397
+ rule_id = @_rule_id_counter
1398
+ @_rule_id_counter += 1
1289
1399
 
1290
- # Skip to opening brace
1291
- while !eof? && peek_byte != BYTE_LBRACE
1292
- @_pos += 1
1400
+ # Create AtRule with declarations
1401
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1402
+ @rules << at_rule
1293
1403
  end
1294
1404
 
1295
- return if eof? || peek_byte != BYTE_LBRACE
1405
+ # Unknown at-rule (@property, @page, @counter-style, etc.) - treated as a
1406
+ # regular selector-based rule with declarations, since this parser has no
1407
+ # special handling for it.
1408
+ #
1409
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1410
+ def parse_unknown_at_rule(at_rule_start)
1411
+ selector = scan_at_rule_selector(at_rule_start)
1412
+ return if selector.nil?
1296
1413
 
1297
- selector_end = @_pos
1298
- # Trim trailing whitespace
1299
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1300
- selector_end -= 1
1301
- end
1302
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1414
+ # Parse declarations
1415
+ declarations = parse_declarations
1303
1416
 
1304
- @_pos += 1 # skip '{'
1417
+ # Create Rule with declarations
1418
+ rule = Rule.new(
1419
+ @_rule_id_counter, # id
1420
+ selector, # selector (e.g., "@property --main-color")
1421
+ declarations, # declarations
1422
+ nil, # specificity
1423
+ nil, # parent_rule_id
1424
+ nil # nesting_style
1425
+ )
1305
1426
 
1306
- # Find matching closing brace
1307
- decl_start = @_pos
1308
- decl_end = find_matching_brace(@_pos)
1427
+ @rules << rule
1428
+ @_rule_id_counter += 1
1429
+ end
1309
1430
 
1310
- # Parse declarations
1311
- content = parse_declarations_block(decl_start, decl_end)
1431
+ # Check if block contains nested selectors vs just declarations
1432
+ # Translated from C: see ext/cataract/css_parser.c has_nested_selectors
1433
+ def has_nested_selectors?(start_pos, end_pos)
1434
+ pos = start_pos
1312
1435
 
1313
- # Move position past the closing brace
1314
- @_pos = decl_end
1315
- # The closing brace should be at decl_end
1316
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1436
+ while pos < end_pos
1437
+ # Skip whitespace
1438
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1439
+ pos += 1
1440
+ end
1441
+ break if pos >= end_pos
1317
1442
 
1318
- # Get rule ID and increment
1319
- rule_id = @_rule_id_counter
1320
- @_rule_id_counter += 1
1443
+ # Skip comments
1444
+ if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
1445
+ pos += 2
1446
+ while pos + 1 < end_pos
1447
+ if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
1448
+ pos += 2
1449
+ break
1450
+ end
1451
+ pos += 1
1452
+ end
1453
+ next
1454
+ end
1321
1455
 
1322
- # Create AtRule with declarations
1323
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1324
- @rules << at_rule
1456
+ # Check for nested selector indicators
1457
+ byte = @_css.getbyte(pos)
1458
+ if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
1459
+ byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
1460
+ byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
1461
+ # Look ahead - if followed by {, it's likely a nested selector
1462
+ lookahead = pos + 1
1463
+ while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
1464
+ @_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
1465
+ lookahead += 1
1466
+ end
1467
+ return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
1468
+ end
1325
1469
 
1326
- return
1327
- end
1470
+ # Check for @media, @supports, etc nested inside
1471
+ return true if byte == BYTE_AT
1328
1472
 
1329
- # Unknown at-rule (@property, @page, @counter-style, etc.)
1330
- # Treat as a regular selector-based rule with declarations
1331
- selector_start = at_rule_start # Points to '@'
1473
+ # Skip to next line or semicolon
1474
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
1475
+ pos += 1
1476
+ end
1477
+ pos += 1 if pos < end_pos
1478
+ end
1332
1479
 
1333
- # Skip to opening brace
1334
- until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
1335
- @_pos += 1
1336
- end
1480
+ false
1481
+ end
1482
+
1483
+ # Resolve nested selector against parent
1484
+ # Translated from C: see ext/cataract/css_parser.c resolve_nested_selector
1485
+ # Examples:
1486
+ # resolve_nested_selector(".parent", "& .child") => [".parent .child", 1] (explicit)
1487
+ # resolve_nested_selector(".parent", "&:hover") => [".parent:hover", 1] (explicit)
1488
+ # resolve_nested_selector(".parent", "&.active") => [".parent.active", 1] (explicit)
1489
+ # resolve_nested_selector(".parent", ".child") => [".parent .child", 0] (implicit)
1490
+ # resolve_nested_selector(".parent", "> .child") => [".parent > .child", 0] (implicit combinator)
1491
+ #
1492
+ # Returns: [resolved_selector, nesting_style]
1493
+ # nesting_style: 0 = NESTING_STYLE_IMPLICIT, 1 = NESTING_STYLE_EXPLICIT
1494
+ def resolve_nested_selector(parent_selector, nested_selector)
1495
+ # Check if nested selector contains & (byte-level search)
1496
+ len = nested_selector.bytesize
1497
+ has_ampersand = false
1498
+ i = 0
1499
+ while i < len
1500
+ if nested_selector.getbyte(i) == BYTE_AMPERSAND
1501
+ has_ampersand = true
1502
+ break
1503
+ end
1504
+ i += 1
1505
+ end
1337
1506
 
1338
- return if eof? || peek_byte != BYTE_LBRACE
1507
+ if has_ampersand
1508
+ # Explicit nesting - replace & with parent
1509
+ nesting_style = NESTING_STYLE_EXPLICIT
1510
+
1511
+ # Trim leading whitespace to check for combinator
1512
+ # NOTE: We use a manual byte-level loop instead of lstrip for performance.
1513
+ # Ruby's lstrip handles all Unicode whitespace and encoding checks, but CSS
1514
+ # selectors only use ASCII whitespace (space, tab, newline, CR). Our loop
1515
+ # checks only these 4 bytes, which benchmarks 1.89x faster than lstrip.
1516
+ start_pos = 0
1517
+ while start_pos < len
1518
+ byte = nested_selector.getbyte(start_pos)
1519
+ break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
1520
+
1521
+ start_pos += 1
1522
+ end
1339
1523
 
1340
- selector_end = @_pos
1341
- # Trim trailing whitespace
1342
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1343
- selector_end -= 1
1344
- end
1345
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1524
+ # Check if selector starts with a combinator (relative selector)
1525
+ starts_with_combinator = false
1526
+ if start_pos < len
1527
+ first_byte = nested_selector.getbyte(start_pos)
1528
+ starts_with_combinator = (first_byte == BYTE_PLUS || first_byte == BYTE_GT || first_byte == BYTE_TILDE)
1529
+ end
1346
1530
 
1347
- @_pos += 1 # skip '{'
1531
+ # Build result by replacing & with parent
1532
+ result = String.new
1533
+ if starts_with_combinator
1534
+ # Prepend parent first with space for relative selectors
1535
+ # Example: "+ .bar + &" => ".foo + .bar + .foo"
1536
+ result << parent_selector
1537
+ result << ' '
1538
+ end
1348
1539
 
1349
- # Parse declarations
1350
- declarations = parse_declarations
1540
+ # Replace all & with parent selector (byte-level iteration)
1541
+ i = 0
1542
+ while i < len
1543
+ byte = nested_selector.getbyte(i)
1544
+ result << if byte == BYTE_AMPERSAND
1545
+ parent_selector
1546
+ else
1547
+ byte.chr
1548
+ end
1549
+ i += 1
1550
+ end
1351
1551
 
1352
- # Create Rule with declarations
1353
- rule = Rule.new(
1354
- @_rule_id_counter, # id
1355
- selector, # selector (e.g., "@property --main-color")
1356
- declarations, # declarations
1357
- nil, # specificity
1358
- nil, # parent_rule_id
1359
- nil # nesting_style
1360
- )
1552
+ [result, nesting_style]
1553
+ else
1554
+ # Implicit nesting - prepend parent with appropriate spacing
1555
+ nesting_style = NESTING_STYLE_IMPLICIT
1361
1556
 
1362
- @rules << rule
1363
- @_rule_id_counter += 1
1364
- end
1557
+ # Trim leading whitespace from nested selector (byte-level)
1558
+ # See comment above for why we don't use lstrip
1559
+ start_pos = 0
1560
+ while start_pos < len
1561
+ byte = nested_selector.getbyte(start_pos)
1562
+ break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
1365
1563
 
1366
- # Check if block contains nested selectors vs just declarations
1367
- # Translated from C: see ext/cataract/css_parser.c has_nested_selectors
1368
- def has_nested_selectors?(start_pos, end_pos)
1369
- pos = start_pos
1564
+ start_pos += 1
1565
+ end
1370
1566
 
1371
- while pos < end_pos
1372
- # Skip whitespace
1373
- while pos < end_pos && whitespace?(@_css.getbyte(pos))
1374
- pos += 1
1375
- end
1376
- break if pos >= end_pos
1567
+ result = String.new
1568
+ result << parent_selector
1569
+ result << ' '
1570
+ result << nested_selector.byteslice(start_pos, nested_selector.bytesize - start_pos)
1377
1571
 
1378
- # Skip comments
1379
- if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
1380
- pos += 2
1381
- while pos + 1 < end_pos
1382
- if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
1383
- pos += 2
1384
- break
1385
- end
1386
- pos += 1
1572
+ [result, nesting_style]
1387
1573
  end
1388
- next
1389
1574
  end
1390
1575
 
1391
- # Check for nested selector indicators
1392
- byte = @_css.getbyte(pos)
1393
- if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
1394
- byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
1395
- byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
1396
- # Look ahead - if followed by {, it's likely a nested selector
1397
- lookahead = pos + 1
1398
- while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
1399
- @_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
1400
- lookahead += 1
1576
+ # Combine parent and child media queries
1577
+ # Translated from C: see ext/cataract/css_parser.c combine_media_queries
1578
+ # Examples:
1579
+ # parent="screen", child="min-width: 500px" => "screen and (min-width: 500px)"
1580
+ # parent=nil, child="print" => "print"
1581
+ def combine_media_queries(parent, child)
1582
+ return child if parent.nil?
1583
+ return parent if child.nil?
1584
+
1585
+ # Combine: "parent and child"
1586
+ parent_str = parent.to_s
1587
+ child_str = child.to_s
1588
+
1589
+ combined = "#{parent_str} and "
1590
+
1591
+ # If child is a condition (contains ':'), wrap it in parentheses
1592
+ combined += if child_str.include?(':')
1593
+ # Add parens if not already present
1594
+ len = child_str.bytesize
1595
+ if len > 1 && child_str.getbyte(0) == BYTE_LPAREN && child_str.getbyte(len - 1) == BYTE_RPAREN
1596
+ child_str
1597
+ else
1598
+ "(#{child_str})"
1599
+ end
1600
+ else
1601
+ child_str
1602
+ end
1603
+
1604
+ combined.to_sym
1605
+ end
1606
+
1607
+ # Skip to next semicolon or closing brace (error recovery)
1608
+ def skip_to_semicolon_or_brace
1609
+ # rubocop:disable Layout/LineLength
1610
+ until eof? || peek_byte == BYTE_SEMICOLON || peek_byte == BYTE_RBRACE # Flip to save a not_opt instruction: while !eof? && peek_byte != BYTE_SEMICOLON && peek_byte != BYTE_RBRACE
1611
+ # rubocop:enable Layout/LineLength
1612
+ @_pos += 1
1401
1613
  end
1402
- return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
1614
+
1615
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1403
1616
  end
1404
1617
 
1405
- # Check for @media, @supports, etc nested inside
1406
- return true if byte == BYTE_AT
1618
+ # Parse an @import statement
1619
+ # @import "url" [media-query];
1620
+ # @import url("url") [media-query];
1621
+ def parse_import_statement
1622
+ skip_ws_and_comments
1407
1623
 
1408
- # Skip to next line or semicolon
1409
- while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
1410
- pos += 1
1411
- end
1412
- pos += 1 if pos < end_pos
1413
- end
1624
+ # Check for optional url(
1625
+ has_url_function = false
1626
+ if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
1627
+ has_url_function = true
1628
+ @_pos += 4
1629
+ skip_ws_and_comments
1630
+ end
1414
1631
 
1415
- false
1416
- end
1632
+ # Find opening quote
1633
+ byte = peek_byte
1634
+ if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
1635
+ # Invalid @import, skip to semicolon
1636
+ while !eof? && peek_byte != BYTE_SEMICOLON
1637
+ @_pos += 1
1638
+ end
1639
+ @_pos += 1 unless eof?
1640
+ return
1641
+ end
1417
1642
 
1418
- # Resolve nested selector against parent
1419
- # Translated from C: see ext/cataract/css_parser.c resolve_nested_selector
1420
- # Examples:
1421
- # resolve_nested_selector(".parent", "& .child") => [".parent .child", 1] (explicit)
1422
- # resolve_nested_selector(".parent", "&:hover") => [".parent:hover", 1] (explicit)
1423
- # resolve_nested_selector(".parent", "&.active") => [".parent.active", 1] (explicit)
1424
- # resolve_nested_selector(".parent", ".child") => [".parent .child", 0] (implicit)
1425
- # resolve_nested_selector(".parent", "> .child") => [".parent > .child", 0] (implicit combinator)
1426
- #
1427
- # Returns: [resolved_selector, nesting_style]
1428
- # nesting_style: 0 = NESTING_STYLE_IMPLICIT, 1 = NESTING_STYLE_EXPLICIT
1429
- def resolve_nested_selector(parent_selector, nested_selector)
1430
- # Check if nested selector contains & (byte-level search)
1431
- len = nested_selector.bytesize
1432
- has_ampersand = false
1433
- i = 0
1434
- while i < len
1435
- if nested_selector.getbyte(i) == BYTE_AMPERSAND
1436
- has_ampersand = true
1437
- break
1438
- end
1439
- i += 1
1440
- end
1643
+ quote_char = byte
1644
+ @_pos += 1 # Skip opening quote
1441
1645
 
1442
- if has_ampersand
1443
- # Explicit nesting - replace & with parent
1444
- nesting_style = NESTING_STYLE_EXPLICIT
1445
-
1446
- # Trim leading whitespace to check for combinator
1447
- # NOTE: We use a manual byte-level loop instead of lstrip for performance.
1448
- # Ruby's lstrip handles all Unicode whitespace and encoding checks, but CSS
1449
- # selectors only use ASCII whitespace (space, tab, newline, CR). Our loop
1450
- # checks only these 4 bytes, which benchmarks 1.89x faster than lstrip.
1451
- start_pos = 0
1452
- while start_pos < len
1453
- byte = nested_selector.getbyte(start_pos)
1454
- break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
1455
-
1456
- start_pos += 1
1457
- end
1646
+ url_start = @_pos
1458
1647
 
1459
- # Check if selector starts with a combinator (relative selector)
1460
- starts_with_combinator = false
1461
- if start_pos < len
1462
- first_byte = nested_selector.getbyte(start_pos)
1463
- starts_with_combinator = (first_byte == BYTE_PLUS || first_byte == BYTE_GT || first_byte == BYTE_TILDE)
1464
- end
1648
+ # Find closing quote (handle escaped quotes)
1649
+ while !eof? && peek_byte != quote_char
1650
+ @_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
1651
+ 2 # Skip escaped character
1652
+ else
1653
+ 1
1654
+ end
1655
+ end
1465
1656
 
1466
- # Build result by replacing & with parent
1467
- result = String.new
1468
- if starts_with_combinator
1469
- # Prepend parent first with space for relative selectors
1470
- # Example: "+ .bar + &" => ".foo + .bar + .foo"
1471
- result << parent_selector
1472
- result << ' '
1473
- end
1657
+ if eof?
1658
+ # Unterminated string
1659
+ return
1660
+ end
1474
1661
 
1475
- # Replace all & with parent selector (byte-level iteration)
1476
- i = 0
1477
- while i < len
1478
- byte = nested_selector.getbyte(i)
1479
- result << if byte == BYTE_AMPERSAND
1480
- parent_selector
1481
- else
1482
- byte.chr
1483
- end
1484
- i += 1
1485
- end
1662
+ url = byteslice_encoded(url_start, @_pos - url_start)
1663
+ @_pos += 1 # Skip closing quote
1486
1664
 
1487
- [result, nesting_style]
1488
- else
1489
- # Implicit nesting - prepend parent with appropriate spacing
1490
- nesting_style = NESTING_STYLE_IMPLICIT
1665
+ # Skip closing paren if we had url(
1666
+ if has_url_function
1667
+ skip_ws_and_comments
1668
+ @_pos += 1 if peek_byte == BYTE_RPAREN
1669
+ end
1491
1670
 
1492
- # Trim leading whitespace from nested selector (byte-level)
1493
- # See comment above for why we don't use lstrip
1494
- start_pos = 0
1495
- while start_pos < len
1496
- byte = nested_selector.getbyte(start_pos)
1497
- break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
1671
+ skip_ws_and_comments
1498
1672
 
1499
- start_pos += 1
1500
- end
1673
+ # Check for optional media query (everything until semicolon)
1674
+ media_string = nil
1675
+ media_query_id = nil
1676
+ if !eof? && peek_byte != BYTE_SEMICOLON
1677
+ media_start = @_pos
1501
1678
 
1502
- result = String.new
1503
- result << parent_selector
1504
- result << ' '
1505
- result << nested_selector.byteslice(start_pos, nested_selector.bytesize - start_pos)
1679
+ # Find semicolon
1680
+ while !eof? && peek_byte != BYTE_SEMICOLON
1681
+ @_pos += 1
1682
+ end
1506
1683
 
1507
- [result, nesting_style]
1508
- end
1509
- end
1684
+ media_end = @_pos
1510
1685
 
1511
- # Combine parent and child media queries
1512
- # Translated from C: see ext/cataract/css_parser.c combine_media_queries
1513
- # Examples:
1514
- # parent="screen", child="min-width: 500px" => "screen and (min-width: 500px)"
1515
- # parent=nil, child="print" => "print"
1516
- def combine_media_queries(parent, child)
1517
- return child if parent.nil?
1518
- return parent if child.nil?
1519
-
1520
- # Combine: "parent and child"
1521
- parent_str = parent.to_s
1522
- child_str = child.to_s
1523
-
1524
- combined = "#{parent_str} and "
1525
-
1526
- # If child is a condition (contains ':'), wrap it in parentheses
1527
- combined += if child_str.include?(':')
1528
- # Add parens if not already present
1529
- len = child_str.bytesize
1530
- if len > 1 && child_str.getbyte(0) == BYTE_LPAREN && child_str.getbyte(len - 1) == BYTE_RPAREN
1531
- child_str
1532
- else
1533
- "(#{child_str})"
1534
- end
1535
- else
1536
- child_str
1537
- end
1686
+ # Trim trailing whitespace from media query
1687
+ while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
1688
+ media_end -= 1
1689
+ end
1538
1690
 
1539
- combined.to_sym
1540
- end
1691
+ if media_end > media_start
1692
+ media_string = byteslice_encoded(media_start, media_end - media_start)
1693
+
1694
+ # Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
1695
+ media_query_strings = media_string.split(',').map(&:strip)
1696
+
1697
+ # Create MediaQuery objects for each query in the list
1698
+ media_query_ids = []
1699
+ media_query_strings.each do |query_string|
1700
+ media_type, media_conditions = parse_media_query_parts(query_string)
1701
+
1702
+ # If we have a parent import's media context, combine them
1703
+ parent_import_type = @_parser_options[:parent_import_media_type]
1704
+ parent_import_conditions = @_parser_options[:parent_import_media_conditions]
1705
+
1706
+ if parent_import_type
1707
+ # Combine: parent's type is the effective type
1708
+ # Conditions are combined with "and"
1709
+ combined_type = parent_import_type
1710
+ combined_conditions = if parent_import_conditions && media_conditions
1711
+ "#{parent_import_conditions} and #{media_conditions}"
1712
+ elsif parent_import_conditions
1713
+ "#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
1714
+ elsif media_conditions
1715
+ media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
1716
+ else
1717
+ media_type == parent_import_type ? nil : media_type.to_s
1718
+ end
1719
+
1720
+ media_type = combined_type
1721
+ media_conditions = combined_conditions
1722
+ end
1723
+
1724
+ # Create MediaQuery object
1725
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1726
+ @media_queries << media_query
1727
+ media_query_ids << @_media_query_id_counter
1728
+ @_media_query_id_counter += 1
1729
+ end
1541
1730
 
1542
- # Skip to next semicolon or closing brace (error recovery)
1543
- def skip_to_semicolon_or_brace
1544
- until eof? || peek_byte == BYTE_SEMICOLON || peek_byte == BYTE_RBRACE # Flip to save a not_opt instruction: while !eof? && peek_byte != BYTE_SEMICOLON && peek_byte != BYTE_RBRACE
1545
- @_pos += 1
1546
- end
1731
+ # Use the first media query ID for the import statement
1732
+ # (The list is tracked separately for serialization)
1733
+ media_query_id = media_query_ids.first
1547
1734
 
1548
- @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1549
- end
1735
+ # If multiple queries, track them as a list for serialization
1736
+ if media_query_ids.size > 1
1737
+ media_query_list_id = @_next_media_query_list_id
1738
+ @_media_query_lists[media_query_list_id] = media_query_ids
1739
+ @_next_media_query_list_id += 1
1740
+ end
1741
+ end
1742
+ end
1550
1743
 
1551
- # Parse an @import statement
1552
- # @import "url" [media-query];
1553
- # @import url("url") [media-query];
1554
- def parse_import_statement
1555
- skip_ws_and_comments
1556
-
1557
- # Check for optional url(
1558
- has_url_function = false
1559
- if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
1560
- has_url_function = true
1561
- @_pos += 4
1562
- skip_ws_and_comments
1563
- end
1744
+ # Skip semicolon
1745
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
1564
1746
 
1565
- # Find opening quote
1566
- byte = peek_byte
1567
- if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
1568
- # Invalid @import, skip to semicolon
1569
- while !eof? && peek_byte != BYTE_SEMICOLON
1570
- @_pos += 1
1747
+ # Create ImportStatement (resolved: false by default)
1748
+ import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
1749
+ @imports << import_stmt
1750
+ @_rule_id_counter += 1
1571
1751
  end
1572
- @_pos += 1 unless eof?
1573
- return
1574
- end
1575
1752
 
1576
- quote_char = byte
1577
- @_pos += 1 # Skip opening quote
1753
+ # Convert relative URLs in a value string to absolute URLs
1754
+ # Called when @_absolute_paths is enabled and @_base_uri is set
1755
+ #
1756
+ # @param value [String] The declaration value to process
1757
+ # @return [String] Value with relative URLs converted to absolute
1758
+ def convert_urls_in_value(value)
1759
+ return value unless @_absolute_paths && @_base_uri
1760
+
1761
+ result = +''
1762
+ pos = 0
1763
+ len = value.bytesize
1764
+
1765
+ while pos < len
1766
+ # Look for 'url(' - case insensitive
1767
+ byte = value.getbyte(pos)
1768
+ if pos + 3 < len &&
1769
+ (byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
1770
+ (value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
1771
+ (value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
1772
+ value.getbyte(pos + 3) == BYTE_LPAREN
1773
+
1774
+ result << value.byteslice(pos, 4) # append 'url('
1775
+ pos += 4
1776
+
1777
+ # Skip whitespace
1778
+ while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1779
+ result << value.getbyte(pos).chr
1780
+ pos += 1
1781
+ end
1578
1782
 
1579
- url_start = @_pos
1783
+ # Check for quote
1784
+ quote_char = nil
1785
+ if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
1786
+ quote_char = value.getbyte(pos)
1787
+ pos += 1
1788
+ end
1580
1789
 
1581
- # Find closing quote (handle escaped quotes)
1582
- while !eof? && peek_byte != quote_char
1583
- @_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
1584
- 2 # Skip escaped character
1585
- else
1586
- 1
1587
- end
1588
- end
1790
+ # Extract URL
1791
+ url_start = pos
1792
+ if quote_char
1793
+ # Scan until matching quote
1794
+ while pos < len && value.getbyte(pos) != quote_char
1795
+ # Handle escape
1796
+ pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
1797
+ 2
1798
+ else
1799
+ 1
1800
+ end
1801
+ end
1802
+ else
1803
+ # Scan until ) or whitespace
1804
+ while pos < len
1805
+ b = value.getbyte(pos)
1806
+ break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
1589
1807
 
1590
- if eof?
1591
- # Unterminated string
1592
- return
1593
- end
1808
+ pos += 1
1809
+ end
1810
+ end
1594
1811
 
1595
- url = byteslice_encoded(url_start, @_pos - url_start)
1596
- @_pos += 1 # Skip closing quote
1812
+ url_str = value.byteslice(url_start, pos - url_start)
1597
1813
 
1598
- # Skip closing paren if we had url(
1599
- if has_url_function
1600
- skip_ws_and_comments
1601
- @_pos += 1 if peek_byte == BYTE_RPAREN
1602
- end
1814
+ # Check if URL needs resolution (is relative)
1815
+ # Skip if: contains "://" OR starts with "data:"
1816
+ needs_resolution = true
1817
+ if url_str.empty?
1818
+ needs_resolution = false
1819
+ else
1820
+ # Check for "://"
1821
+ i = 0
1822
+ url_len = url_str.bytesize
1823
+ while i + 2 < url_len
1824
+ if url_str.getbyte(i) == BYTE_COLON &&
1825
+ url_str.getbyte(i + 1) == BYTE_SLASH &&
1826
+ url_str.getbyte(i + 2) == BYTE_SLASH
1827
+ needs_resolution = false
1828
+ break
1829
+ end
1830
+ i += 1
1831
+ end
1832
+
1833
+ # Check for "data:" prefix (case insensitive)
1834
+ if needs_resolution && url_len >= 5
1835
+ if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
1836
+ (url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
1837
+ (url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
1838
+ (url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
1839
+ url_str.getbyte(4) == BYTE_COLON
1840
+ needs_resolution = false
1841
+ end
1842
+ end
1843
+ end
1603
1844
 
1604
- skip_ws_and_comments
1845
+ if needs_resolution
1846
+ # Resolve relative URL using the resolver proc
1847
+ begin
1848
+ resolved = @_uri_resolver.call(@_base_uri, url_str)
1849
+ result << "'" << resolved << "'"
1850
+ rescue StandardError
1851
+ # If resolution fails, preserve original
1852
+ if quote_char
1853
+ result << quote_char.chr << url_str << quote_char.chr
1854
+ else
1855
+ result << url_str
1856
+ end
1857
+ end
1858
+ elsif url_str.empty?
1859
+ # Preserve original URL
1860
+ result << "''"
1861
+ elsif quote_char
1862
+ result << quote_char.chr << url_str << quote_char.chr
1863
+ else
1864
+ result << url_str
1865
+ end
1605
1866
 
1606
- # Check for optional media query (everything until semicolon)
1607
- media_string = nil
1608
- media_query_id = nil
1609
- if !eof? && peek_byte != BYTE_SEMICOLON
1610
- media_start = @_pos
1867
+ # Skip past closing quote if present
1868
+ pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
1611
1869
 
1612
- # Find semicolon
1613
- while !eof? && peek_byte != BYTE_SEMICOLON
1614
- @_pos += 1
1615
- end
1870
+ # Skip whitespace before )
1871
+ while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1872
+ pos += 1
1873
+ end
1616
1874
 
1617
- media_end = @_pos
1875
+ # The ) will be copied in the next iteration or at the end
1876
+ else
1877
+ result << byte.chr
1878
+ pos += 1
1879
+ end
1880
+ end
1618
1881
 
1619
- # Trim trailing whitespace from media query
1620
- while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
1621
- media_end -= 1
1882
+ result
1622
1883
  end
1623
1884
 
1624
- if media_end > media_start
1625
- media_string = byteslice_encoded(media_start, media_end - media_start)
1626
-
1627
- # Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
1628
- media_query_strings = media_string.split(',').map(&:strip)
1885
+ # Parse a block of declarations given start/end positions
1886
+ # Used for @font-face and other at-rules
1887
+ # Translated from C: see ext/cataract/css_parser.c parse_declarations
1888
+ def parse_declarations_block(start_pos, end_pos)
1889
+ declarations = []
1890
+ pos = start_pos
1629
1891
 
1630
- # Create MediaQuery objects for each query in the list
1631
- media_query_ids = []
1632
- media_query_strings.each do |query_string|
1633
- media_type, media_conditions = parse_media_query_parts(query_string)
1634
-
1635
- # If we have a parent import's media context, combine them
1636
- parent_import_type = @_parser_options[:parent_import_media_type]
1637
- parent_import_conditions = @_parser_options[:parent_import_media_conditions]
1638
-
1639
- if parent_import_type
1640
- # Combine: parent's type is the effective type
1641
- # Conditions are combined with "and"
1642
- combined_type = parent_import_type
1643
- combined_conditions = if parent_import_conditions && media_conditions
1644
- "#{parent_import_conditions} and #{media_conditions}"
1645
- elsif parent_import_conditions
1646
- "#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
1647
- elsif media_conditions
1648
- media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
1649
- else
1650
- media_type == parent_import_type ? nil : media_type.to_s
1651
- end
1652
-
1653
- media_type = combined_type
1654
- media_conditions = combined_conditions
1892
+ while pos < end_pos
1893
+ # Skip whitespace
1894
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1895
+ pos += 1
1655
1896
  end
1897
+ break if pos >= end_pos
1656
1898
 
1657
- # Create MediaQuery object
1658
- media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1659
- @media_queries << media_query
1660
- media_query_ids << @_media_query_id_counter
1661
- @_media_query_id_counter += 1
1899
+ # Parse declaration using shared helper (at-rules don't use !important)
1900
+ decl, pos = parse_single_declaration(pos, end_pos, false)
1901
+ declarations << decl if decl
1662
1902
  end
1663
1903
 
1664
- # Use the first media query ID for the import statement
1665
- # (The list is tracked separately for serialization)
1666
- media_query_id = media_query_ids.first
1904
+ declarations
1905
+ end
1906
+
1907
+ # Combine parent and child media query parts directly without string building
1908
+ #
1909
+ # The parent's type takes precedence (child type is ignored per CSS spec).
1910
+ #
1911
+ # @param parent_mq [MediaQuery] Parent media query object
1912
+ # @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
1913
+ # @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
1914
+ #
1915
+ # @example
1916
+ # combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
1917
+ def combine_media_query_parts(parent_mq, child_conditions)
1918
+ # Type: parent's type wins (outermost type)
1919
+ combined_type = parent_mq.type
1920
+
1921
+ # Conditions: combine parent and child conditions
1922
+ combined_conditions = if parent_mq.conditions && child_conditions
1923
+ "#{parent_mq.conditions} and #{child_conditions}"
1924
+ elsif parent_mq.conditions
1925
+ parent_mq.conditions
1926
+ elsif child_conditions
1927
+ child_conditions
1928
+ end
1667
1929
 
1668
- # If multiple queries, track them as a list for serialization
1669
- if media_query_ids.size > 1
1670
- media_query_list_id = @_next_media_query_list_id
1671
- @_media_query_lists[media_query_list_id] = media_query_ids
1672
- @_next_media_query_list_id += 1
1930
+ [combined_type, combined_conditions]
1931
+ end
1932
+
1933
+ # Parse media query string into type and conditions
1934
+ #
1935
+ # @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
1936
+ # @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
1937
+ #
1938
+ # @example
1939
+ # parse_media_query_parts("screen") #=> [:screen, nil]
1940
+ # parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
1941
+ # parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
1942
+ def parse_media_query_parts(query)
1943
+ i = 0
1944
+ len = query.bytesize
1945
+
1946
+ # Skip leading whitespace
1947
+ while i < len && whitespace?(query.getbyte(i))
1948
+ i += 1
1673
1949
  end
1674
- end
1675
- end
1676
1950
 
1677
- # Skip semicolon
1678
- @_pos += 1 if peek_byte == BYTE_SEMICOLON
1679
-
1680
- # Create ImportStatement (resolved: false by default)
1681
- import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
1682
- @imports << import_stmt
1683
- @_rule_id_counter += 1
1684
- end
1951
+ return [:all, nil] if i >= len
1685
1952
 
1686
- # Convert relative URLs in a value string to absolute URLs
1687
- # Called when @_absolute_paths is enabled and @_base_uri is set
1688
- #
1689
- # @param value [String] The declaration value to process
1690
- # @return [String] Value with relative URLs converted to absolute
1691
- def convert_urls_in_value(value)
1692
- return value unless @_absolute_paths && @_base_uri
1693
-
1694
- result = +''
1695
- pos = 0
1696
- len = value.bytesize
1697
-
1698
- while pos < len
1699
- # Look for 'url(' - case insensitive
1700
- byte = value.getbyte(pos)
1701
- if pos + 3 < len &&
1702
- (byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
1703
- (value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
1704
- (value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
1705
- value.getbyte(pos + 3) == BYTE_LPAREN
1706
-
1707
- result << value.byteslice(pos, 4) # append 'url('
1708
- pos += 4
1709
-
1710
- # Skip whitespace
1711
- while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1712
- result << value.getbyte(pos).chr
1713
- pos += 1
1953
+ # Check if starts with '(' - media feature without type (defaults to :all)
1954
+ if query.getbyte(i) == BYTE_LPAREN
1955
+ return [:all, query.byteslice(i, len - i)]
1714
1956
  end
1715
1957
 
1716
- # Check for quote
1717
- quote_char = nil
1718
- if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
1719
- quote_char = value.getbyte(pos)
1720
- pos += 1
1958
+ # Find first media type word
1959
+ word_start = i
1960
+ while i < len
1961
+ byte = query.getbyte(i)
1962
+ break if whitespace?(byte) || byte == BYTE_LPAREN
1963
+
1964
+ i += 1
1721
1965
  end
1722
1966
 
1723
- # Extract URL
1724
- url_start = pos
1725
- if quote_char
1726
- # Scan until matching quote
1727
- while pos < len && value.getbyte(pos) != quote_char
1728
- # Handle escape
1729
- pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
1730
- 2
1731
- else
1732
- 1
1733
- end
1734
- end
1735
- else
1736
- # Scan until ) or whitespace
1737
- while pos < len
1738
- b = value.getbyte(pos)
1739
- break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
1967
+ type = query.byteslice(word_start, i - word_start).to_sym
1740
1968
 
1741
- pos += 1
1742
- end
1969
+ # Skip whitespace after type
1970
+ while i < len && whitespace?(query.getbyte(i))
1971
+ i += 1
1743
1972
  end
1744
1973
 
1745
- url_str = value.byteslice(url_start, pos - url_start)
1974
+ # Check if there's more (conditions)
1975
+ if i >= len
1976
+ return [type, nil]
1977
+ end
1746
1978
 
1747
- # Check if URL needs resolution (is relative)
1748
- # Skip if: contains "://" OR starts with "data:"
1749
- needs_resolution = true
1750
- if url_str.empty?
1751
- needs_resolution = false
1752
- else
1753
- # Check for "://"
1754
- i = 0
1755
- url_len = url_str.bytesize
1756
- while i + 2 < url_len
1757
- if url_str.getbyte(i) == BYTE_COLON &&
1758
- url_str.getbyte(i + 1) == BYTE_SLASH &&
1759
- url_str.getbyte(i + 2) == BYTE_SLASH
1760
- needs_resolution = false
1979
+ # Look for " and " keyword (case-insensitive)
1980
+ # We need to find "and" as a separate word
1981
+ and_pos = nil
1982
+ check_i = i
1983
+ while check_i < len - 2
1984
+ # Check for 'and' (a=97/65, n=110/78, d=100/68)
1985
+ byte0 = query.getbyte(check_i)
1986
+ byte1 = query.getbyte(check_i + 1)
1987
+ byte2 = query.getbyte(check_i + 2)
1988
+
1989
+ if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
1990
+ (byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
1991
+ (byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
1992
+ # Make sure it's a word boundary (whitespace before and after)
1993
+ before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
1994
+ after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
1995
+ if before_ok && after_ok
1996
+ and_pos = check_i
1761
1997
  break
1762
1998
  end
1763
- i += 1
1764
- end
1765
-
1766
- # Check for "data:" prefix (case insensitive)
1767
- if needs_resolution && url_len >= 5
1768
- if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
1769
- (url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
1770
- (url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
1771
- (url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
1772
- url_str.getbyte(4) == BYTE_COLON
1773
- needs_resolution = false
1774
- end
1775
1999
  end
2000
+ check_i += 1
1776
2001
  end
1777
2002
 
1778
- if needs_resolution
1779
- # Resolve relative URL using the resolver proc
1780
- begin
1781
- resolved = @_uri_resolver.call(@_base_uri, url_str)
1782
- result << "'" << resolved << "'"
1783
- rescue StandardError
1784
- # If resolution fails, preserve original
1785
- if quote_char
1786
- result << quote_char.chr << url_str << quote_char.chr
1787
- else
1788
- result << url_str
1789
- end
2003
+ if and_pos
2004
+ # Skip past "and " to get conditions
2005
+ conditions_start = and_pos + 3 # skip "and"
2006
+ while conditions_start < len && whitespace?(query.getbyte(conditions_start))
2007
+ conditions_start += 1
1790
2008
  end
1791
- elsif url_str.empty?
1792
- # Preserve original URL
1793
- result << "''"
1794
- elsif quote_char
1795
- result << quote_char.chr << url_str << quote_char.chr
2009
+ conditions = query.byteslice(conditions_start, len - conditions_start)
2010
+ [type, conditions]
1796
2011
  else
1797
- result << url_str
1798
- end
1799
-
1800
- # Skip past closing quote if present
1801
- pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
1802
-
1803
- # Skip whitespace before )
1804
- while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1805
- pos += 1
2012
+ # No "and" found - rest is conditions (unusual but possible)
2013
+ [type, query.byteslice(i, len - i)]
1806
2014
  end
1807
-
1808
- # The ) will be copied in the next iteration or at the end
1809
- else
1810
- result << byte.chr
1811
- pos += 1
1812
- end
1813
- end
1814
-
1815
- result
1816
- end
1817
-
1818
- # Parse a block of declarations given start/end positions
1819
- # Used for @font-face and other at-rules
1820
- # Translated from C: see ext/cataract/css_parser.c parse_declarations
1821
- def parse_declarations_block(start_pos, end_pos)
1822
- declarations = []
1823
- pos = start_pos
1824
-
1825
- while pos < end_pos
1826
- # Skip whitespace
1827
- while pos < end_pos && whitespace?(@_css.getbyte(pos))
1828
- pos += 1
1829
- end
1830
- break if pos >= end_pos
1831
-
1832
- # Parse declaration using shared helper (at-rules don't use !important)
1833
- decl, pos = parse_single_declaration(pos, end_pos, false)
1834
- declarations << decl if decl
1835
- end
1836
-
1837
- declarations
1838
- end
1839
-
1840
- # Combine parent and child media query parts directly without string building
1841
- #
1842
- # The parent's type takes precedence (child type is ignored per CSS spec).
1843
- #
1844
- # @param parent_mq [MediaQuery] Parent media query object
1845
- # @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
1846
- # @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
1847
- #
1848
- # @example
1849
- # combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
1850
- def combine_media_query_parts(parent_mq, child_conditions)
1851
- # Type: parent's type wins (outermost type)
1852
- combined_type = parent_mq.type
1853
-
1854
- # Conditions: combine parent and child conditions
1855
- combined_conditions = if parent_mq.conditions && child_conditions
1856
- "#{parent_mq.conditions} and #{child_conditions}"
1857
- elsif parent_mq.conditions
1858
- parent_mq.conditions
1859
- elsif child_conditions
1860
- child_conditions
1861
- end
1862
-
1863
- [combined_type, combined_conditions]
1864
- end
1865
-
1866
- # Parse media query string into type and conditions
1867
- #
1868
- # @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
1869
- # @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
1870
- #
1871
- # @example
1872
- # parse_media_query_parts("screen") #=> [:screen, nil]
1873
- # parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
1874
- # parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
1875
- def parse_media_query_parts(query)
1876
- i = 0
1877
- len = query.bytesize
1878
-
1879
- # Skip leading whitespace
1880
- while i < len && whitespace?(query.getbyte(i))
1881
- i += 1
1882
- end
1883
-
1884
- return [:all, nil] if i >= len
1885
-
1886
- # Check if starts with '(' - media feature without type (defaults to :all)
1887
- if query.getbyte(i) == BYTE_LPAREN
1888
- return [:all, query.byteslice(i, len - i)]
1889
- end
1890
-
1891
- # Find first media type word
1892
- word_start = i
1893
- while i < len
1894
- byte = query.getbyte(i)
1895
- break if whitespace?(byte) || byte == BYTE_LPAREN
1896
-
1897
- i += 1
1898
- end
1899
-
1900
- type = query.byteslice(word_start, i - word_start).to_sym
1901
-
1902
- # Skip whitespace after type
1903
- while i < len && whitespace?(query.getbyte(i))
1904
- i += 1
1905
- end
1906
-
1907
- # Check if there's more (conditions)
1908
- if i >= len
1909
- return [type, nil]
1910
- end
1911
-
1912
- # Look for " and " keyword (case-insensitive)
1913
- # We need to find "and" as a separate word
1914
- and_pos = nil
1915
- check_i = i
1916
- while check_i < len - 2
1917
- # Check for 'and' (a=97/65, n=110/78, d=100/68)
1918
- byte0 = query.getbyte(check_i)
1919
- byte1 = query.getbyte(check_i + 1)
1920
- byte2 = query.getbyte(check_i + 2)
1921
-
1922
- if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
1923
- (byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
1924
- (byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
1925
- # Make sure it's a word boundary (whitespace before and after)
1926
- before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
1927
- after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
1928
- if before_ok && after_ok
1929
- and_pos = check_i
1930
- break
1931
- end
1932
- end
1933
- check_i += 1
1934
- end
1935
-
1936
- if and_pos
1937
- # Skip past "and " to get conditions
1938
- conditions_start = and_pos + 3 # skip "and"
1939
- while conditions_start < len && whitespace?(query.getbyte(conditions_start))
1940
- conditions_start += 1
1941
2015
  end
1942
- conditions = query.byteslice(conditions_start, len - conditions_start)
1943
- [type, conditions]
1944
- else
1945
- # No "and" found - rest is conditions (unusual but possible)
1946
- [type, query.byteslice(i, len - i)]
1947
2016
  end
1948
2017
  end
1949
2018
  end