cataract 0.3.0 → 0.4.0

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