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