cataract 0.2.3 → 0.2.5

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.
@@ -29,15 +29,13 @@ module Cataract
29
29
 
30
30
  AT_RULE_TYPES = %w[supports layer container scope].freeze
31
31
 
32
- attr_reader :css, :pos, :len
33
-
34
32
  # Extract substring and force specified encoding
35
33
  # Per CSS spec, charset detection happens at byte-stream level before parsing.
36
34
  # All parsing operations treat content as UTF-8 (spec requires fallback to UTF-8).
37
35
  # This prevents ArgumentError on broken/invalid encodings when calling string methods.
38
36
  # Optional encoding parameter (default: 'UTF-8', use 'US-ASCII' for property names)
39
37
  def byteslice_encoded(start, length, encoding: 'UTF-8')
40
- @css.byteslice(start, length).force_encoding(encoding)
38
+ @_css.byteslice(start, length).force_encoding(encoding)
41
39
  end
42
40
 
43
41
  # Helper: Case-insensitive ASCII byte comparison
@@ -65,31 +63,79 @@ module Cataract
65
63
  true
66
64
  end
67
65
 
68
- def initialize(css_string, parser_options: {}, parent_media_sym: nil, depth: 0)
69
- @css = css_string.dup.freeze
70
- @pos = 0
71
- @len = @css.bytesize
72
- @parent_media_sym = parent_media_sym
73
-
74
- # Parser options with defaults
75
- @parser_options = {
76
- selector_lists: true
66
+ def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
67
+ # Type validation
68
+ raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
69
+
70
+ # Private: Internal parsing state
71
+ @_css = css_string.dup.freeze
72
+ @_pos = 0
73
+ @_len = @_css.bytesize
74
+ @_parent_media_sym = parent_media_sym
75
+ @_parent_media_query_id = parent_media_query_id
76
+ @_depth = depth # Current recursion depth (passed from parent parser)
77
+
78
+ # Private: Parser options with defaults
79
+ @_parser_options = {
80
+ selector_lists: true,
81
+ base_uri: nil,
82
+ absolute_paths: false,
83
+ uri_resolver: nil,
84
+ raise_parse_errors: false
77
85
  }.merge(parser_options)
78
86
 
79
- # Extract selector_lists option to ivar to avoid repeated hash lookups in hot path
80
- @selector_lists_enabled = @parser_options[:selector_lists]
87
+ # Private: Extract options to ivars to avoid repeated hash lookups in hot path
88
+ @_selector_lists_enabled = @_parser_options[:selector_lists]
89
+ @_base_uri = @_parser_options[:base_uri]
90
+ @_absolute_paths = @_parser_options[:absolute_paths]
91
+ @_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
92
+
93
+ # Parse error handling options - extract to ivars for hot path performance
94
+ @_raise_parse_errors = @_parser_options[:raise_parse_errors]
95
+ if @_raise_parse_errors.is_a?(Hash)
96
+ # Granular control - default all to false (opt-in)
97
+ @_check_empty_values = @_raise_parse_errors[:empty_values] || false
98
+ @_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
99
+ @_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
100
+ @_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
101
+ @_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
102
+ @_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
103
+ elsif @_raise_parse_errors == true
104
+ # Enable all error checks
105
+ @_check_empty_values = true
106
+ @_check_malformed_declarations = true
107
+ @_check_invalid_selectors = true
108
+ @_check_invalid_selector_syntax = true
109
+ @_check_malformed_at_rules = true
110
+ @_check_unclosed_blocks = true
111
+ else
112
+ # Disabled
113
+ @_check_empty_values = false
114
+ @_check_malformed_declarations = false
115
+ @_check_invalid_selectors = false
116
+ @_check_invalid_selector_syntax = false
117
+ @_check_malformed_at_rules = false
118
+ @_check_unclosed_blocks = false
119
+ end
81
120
 
82
- # Parser state
83
- @rules = [] # Flat array of Rule structs
84
- @_media_index = {} # Symbol => Array of rule IDs
85
- @_selector_lists = {} # Hash: list_id => Array of rule IDs
121
+ # Private: Internal counters
122
+ @_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
86
123
  @_next_selector_list_id = 0 # Counter for selector list IDs
124
+ @_next_media_query_list_id = 0 # Counter for media query list IDs
125
+ @_rule_id_counter = 0 # Next rule ID (0-indexed)
126
+ @_media_query_count = 0 # Safety limit
127
+
128
+ # Public: Parser results (returned in parse result hash)
129
+ @rules = [] # Flat array of Rule structs
130
+ @media_queries = [] # Array of MediaQuery objects
131
+ @media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
87
132
  @imports = [] # Array of ImportStatement structs
88
- @rule_id_counter = 0 # Next rule ID (0-indexed)
89
- @media_query_count = 0 # Safety limit
90
- @_has_nesting = false # Set to true if any nested rules found
91
- @depth = depth # Current recursion depth (passed from parent parser)
92
133
  @charset = nil # @charset declaration
134
+
135
+ # Semi-private: Internal state exposed with _ prefix in result
136
+ @_selector_lists = {} # Hash: list_id => Array of rule IDs
137
+ @_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
138
+ @_has_nesting = false # Set to true if any nested rules found
93
139
  end
94
140
 
95
141
  def parse
@@ -118,7 +164,7 @@ module Cataract
118
164
  end
119
165
 
120
166
  # Find the block boundaries
121
- decl_start = @pos # Should be right after the {
167
+ decl_start = @_pos # Should be right after the {
122
168
  decl_end = find_matching_brace(decl_start)
123
169
 
124
170
  # Check if block has nested selectors
@@ -129,21 +175,28 @@ module Cataract
129
175
 
130
176
  selectors.each do |individual_selector|
131
177
  individual_selector.strip!
178
+
179
+ # Check for empty selector in comma-separated list
180
+ if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
181
+ raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
182
+ css: @_css, pos: decl_start, type: :invalid_selector_syntax)
183
+ end
184
+
132
185
  next if individual_selector.empty?
133
186
 
134
187
  # Get rule ID for this selector
135
- current_rule_id = @rule_id_counter
136
- @rule_id_counter += 1
188
+ current_rule_id = @_rule_id_counter
189
+ @_rule_id_counter += 1
137
190
 
138
191
  # Reserve parent's position in rules array (ensures parent comes before nested)
139
192
  parent_position = @rules.length
140
193
  @rules << nil # Placeholder
141
194
 
142
195
  # Parse mixed block (declarations + nested selectors)
143
- @depth += 1
196
+ @_depth += 1
144
197
  parent_declarations = parse_mixed_block(decl_start, decl_end,
145
- individual_selector, current_rule_id, @parent_media_sym)
146
- @depth -= 1
198
+ individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
199
+ @_depth -= 1
147
200
 
148
201
  # Create parent rule and replace placeholder
149
202
  rule = Rule.new(
@@ -156,16 +209,14 @@ module Cataract
156
209
  )
157
210
 
158
211
  @rules[parent_position] = rule
159
- @_media_index[@parent_media_sym] ||= [] if @parent_media_sym
160
- @_media_index[@parent_media_sym] << current_rule_id if @parent_media_sym
161
212
  end
162
213
 
163
214
  # Move position past the closing brace
164
- @pos = decl_end
165
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
215
+ @_pos = decl_end
216
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
166
217
  else
167
218
  # NON-NESTED PATH: Parse declarations only
168
- @pos = decl_start # Reset to start of block
219
+ @_pos = decl_start # Reset to start of block
169
220
  declarations = parse_declarations
170
221
 
171
222
  # Split comma-separated selectors into individual rules
@@ -174,7 +225,7 @@ module Cataract
174
225
  # Determine if we should track this as a selector list
175
226
  # Check boolean first to potentially avoid size() call via short-circuit evaluation
176
227
  list_id = nil
177
- if @selector_lists_enabled && selectors.size > 1
228
+ if @_selector_lists_enabled && selectors.size > 1
178
229
  list_id = @_next_selector_list_id
179
230
  @_next_selector_list_id += 1
180
231
  @_selector_lists[list_id] = []
@@ -182,9 +233,16 @@ module Cataract
182
233
 
183
234
  selectors.each do |individual_selector|
184
235
  individual_selector.strip!
236
+
237
+ # Check for empty selector in comma-separated list
238
+ if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
239
+ raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
240
+ css: @_css, pos: decl_start, type: :invalid_selector_syntax)
241
+ end
242
+
185
243
  next if individual_selector.empty?
186
244
 
187
- rule_id = @rule_id_counter
245
+ rule_id = @_rule_id_counter
188
246
 
189
247
  # Dup declarations for each rule in a selector list to avoid shared state
190
248
  # (principle of least surprise - modifying one rule shouldn't affect others)
@@ -207,7 +265,7 @@ module Cataract
207
265
  )
208
266
 
209
267
  @rules << rule
210
- @rule_id_counter += 1
268
+ @_rule_id_counter += 1
211
269
 
212
270
  # Track in selector list if applicable
213
271
  @_selector_lists[list_id] << rule_id if list_id
@@ -217,8 +275,10 @@ module Cataract
217
275
 
218
276
  {
219
277
  rules: @rules,
220
- _media_index: @_media_index,
278
+ _media_index: @media_index,
279
+ media_queries: @media_queries,
221
280
  _selector_lists: @_selector_lists,
281
+ _media_query_lists: @_media_query_lists,
222
282
  imports: @imports,
223
283
  charset: @charset,
224
284
  _has_nesting: @_has_nesting
@@ -229,7 +289,7 @@ module Cataract
229
289
 
230
290
  # Check if we're at end of input
231
291
  def eof?
232
- @pos >= @len
292
+ @_pos >= @_len
233
293
  end
234
294
 
235
295
  # Peek current byte without advancing
@@ -237,7 +297,7 @@ module Cataract
237
297
  def peek_byte
238
298
  return nil if eof?
239
299
 
240
- @css.getbyte(@pos)
300
+ @_css.getbyte(@_pos)
241
301
  end
242
302
 
243
303
  # Delegate to module-level helper methods (now work with bytes)
@@ -258,19 +318,19 @@ module Cataract
258
318
  end
259
319
 
260
320
  def skip_whitespace
261
- @pos += 1 while !eof? && whitespace?(peek_byte)
321
+ @_pos += 1 while !eof? && whitespace?(peek_byte)
262
322
  end
263
323
 
264
324
  def skip_comment # rubocop:disable Naming/PredicateMethod
265
- return false unless peek_byte == BYTE_SLASH && @css.getbyte(@pos + 1) == BYTE_STAR
325
+ return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
266
326
 
267
- @pos += 2 # Skip /*
268
- while @pos + 1 < @len
269
- if @css.getbyte(@pos) == BYTE_STAR && @css.getbyte(@pos + 1) == BYTE_SLASH
270
- @pos += 2 # Skip */
327
+ @_pos += 2 # Skip /*
328
+ while @_pos + 1 < @_len
329
+ if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
330
+ @_pos += 2 # Skip */
271
331
  return true
272
332
  end
273
- @pos += 1
333
+ @_pos += 1
274
334
  end
275
335
  true
276
336
  end
@@ -283,10 +343,58 @@ module Cataract
283
343
  # Benchmark shows 15-51% speedup depending on YJIT
284
344
  def skip_ws_and_comments
285
345
  begin
286
- old_pos = @pos
346
+ old_pos = @_pos
287
347
  skip_whitespace
288
348
  skip_comment
289
- end until @pos == old_pos # No progress made # rubocop:disable Lint/Loop
349
+ end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
350
+ end
351
+
352
+ # Check if a selector contains only valid CSS selector characters and sequences
353
+ # Returns true if valid, false if invalid
354
+ # Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
355
+ def valid_selector_syntax?(selector_text)
356
+ i = 0
357
+ len = selector_text.bytesize
358
+
359
+ while i < len
360
+ byte = selector_text.getbyte(i)
361
+
362
+ # Check for invalid character sequences
363
+ if i + 1 < len
364
+ next_byte = selector_text.getbyte(i + 1)
365
+ # Double dot (..) is invalid
366
+ return false if byte == BYTE_DOT && next_byte == BYTE_DOT
367
+ # Double hash (##) is invalid
368
+ return false if byte == BYTE_HASH && next_byte == BYTE_HASH
369
+ end
370
+
371
+ # Alphanumeric
372
+ if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
373
+ i += 1
374
+ next
375
+ end
376
+
377
+ # Whitespace
378
+ if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
379
+ i += 1
380
+ next
381
+ end
382
+
383
+ # Valid CSS selector special characters
384
+ case byte
385
+ when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
386
+ BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
387
+ BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
388
+ BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
389
+ BYTE_COMMA
390
+ i += 1
391
+ else
392
+ # Invalid character found
393
+ return false
394
+ end
395
+ end
396
+
397
+ true
290
398
  end
291
399
 
292
400
  # Parse a single CSS declaration (property: value)
@@ -301,24 +409,24 @@ module Cataract
301
409
  def parse_single_declaration(pos, end_pos, parse_important)
302
410
  # Parse property name (scan until ':')
303
411
  prop_start = pos
304
- while pos < end_pos && @css.getbyte(pos) != BYTE_COLON &&
305
- @css.getbyte(pos) != BYTE_SEMICOLON && @css.getbyte(pos) != BYTE_RBRACE
412
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
413
+ @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
306
414
  pos += 1
307
415
  end
308
416
 
309
417
  # Skip if malformed (no colon found)
310
- if pos >= end_pos || @css.getbyte(pos) != BYTE_COLON
418
+ if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
311
419
  # Error recovery: skip to next semicolon
312
- while pos < end_pos && @css.getbyte(pos) != BYTE_SEMICOLON
420
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
313
421
  pos += 1
314
422
  end
315
- pos += 1 if pos < end_pos && @css.getbyte(pos) == BYTE_SEMICOLON
423
+ pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
316
424
  return [nil, pos]
317
425
  end
318
426
 
319
427
  # Trim trailing whitespace from property
320
428
  prop_end = pos
321
- while prop_end > prop_start && whitespace?(@css.getbyte(prop_end - 1))
429
+ while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
322
430
  prop_end -= 1
323
431
  end
324
432
 
@@ -334,19 +442,19 @@ module Cataract
334
442
  pos += 1 # Skip ':'
335
443
 
336
444
  # Skip leading whitespace in value
337
- while pos < end_pos && whitespace?(@css.getbyte(pos))
445
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
338
446
  pos += 1
339
447
  end
340
448
 
341
449
  # Parse value (scan until ';' or '}')
342
450
  val_start = pos
343
- while pos < end_pos && @css.getbyte(pos) != BYTE_SEMICOLON && @css.getbyte(pos) != BYTE_RBRACE
451
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
344
452
  pos += 1
345
453
  end
346
454
  val_end = pos
347
455
 
348
456
  # Trim trailing whitespace from value
349
- while val_end > val_start && whitespace?(@css.getbyte(val_end - 1))
457
+ while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
350
458
  val_end -= 1
351
459
  end
352
460
 
@@ -361,11 +469,14 @@ module Cataract
361
469
  end
362
470
 
363
471
  # Skip semicolon if present
364
- pos += 1 if pos < end_pos && @css.getbyte(pos) == BYTE_SEMICOLON
472
+ pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
365
473
 
366
474
  # Return nil if empty declaration
367
475
  return [nil, pos] if prop_end <= prop_start || val_end <= val_start
368
476
 
477
+ # Convert relative URLs to absolute if enabled
478
+ value = convert_urls_in_value(value)
479
+
369
480
  [Declaration.new(property, value, important), pos]
370
481
  end
371
482
 
@@ -382,8 +493,8 @@ module Cataract
382
493
  depth = 1
383
494
  pos = start_pos
384
495
 
385
- while pos < @len
386
- byte = @css.getbyte(pos)
496
+ while pos < @_len
497
+ byte = @_css.getbyte(pos)
387
498
  if byte == BYTE_RBRACE
388
499
  depth -= 1
389
500
  return pos if depth == 0
@@ -393,38 +504,67 @@ module Cataract
393
504
  pos += 1
394
505
  end
395
506
 
507
+ # Reached EOF without finding matching closing brace
508
+ if @_check_unclosed_blocks && depth > 0
509
+ raise ParseError.new('Unclosed block: missing closing brace',
510
+ css: @_css, pos: start_pos - 1, type: :unclosed_block)
511
+ end
512
+
396
513
  pos
397
514
  end
398
515
 
399
516
  # Parse selector (read until '{')
400
517
  def parse_selector
401
- start_pos = @pos
518
+ start_pos = @_pos
402
519
 
403
520
  # Read until we find '{'
404
521
  until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
405
- @pos += 1
522
+ @_pos += 1
406
523
  end
407
524
 
408
525
  # If we hit EOF without finding '{', return nil
409
526
  return nil if eof?
410
527
 
411
528
  # Extract selector text
412
- selector_text = byteslice_encoded(start_pos, @pos - start_pos)
529
+ selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
413
530
 
414
531
  # Skip the '{'
415
- @pos += 1 if peek_byte == BYTE_LBRACE
532
+ @_pos += 1 if peek_byte == BYTE_LBRACE
416
533
 
417
534
  # Trim whitespace from selector (in-place to avoid allocation)
418
535
  selector_text.strip!
536
+
537
+ # Validate selector (strict mode) - only if enabled to avoid overhead
538
+ if @_check_invalid_selectors
539
+ # Check for empty selector
540
+ if selector_text.empty?
541
+ raise ParseError.new('Invalid selector: empty selector',
542
+ css: @_css, pos: start_pos, type: :invalid_selector)
543
+ end
544
+
545
+ # Check if selector starts with a combinator (>, +, ~)
546
+ first_char = selector_text.getbyte(0)
547
+ if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
548
+ raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
549
+ css: @_css, pos: start_pos, type: :invalid_selector)
550
+ end
551
+ end
552
+
553
+ # Check selector syntax (whitelist validation for invalid characters/sequences)
554
+ if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
555
+ raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
556
+ css: @_css, pos: start_pos, type: :invalid_selector_syntax)
557
+ end
558
+
419
559
  selector_text
420
560
  end
421
561
 
422
562
  # Parse mixed block containing declarations AND nested selectors/at-rules
423
563
  # Translated from C: see ext/cataract/css_parser.c parse_mixed_block
424
564
  # Returns: Array of declarations (only the declarations, not nested rules)
425
- def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym)
565
+ def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
426
566
  # Check recursion depth to prevent stack overflow
427
- if @depth > MAX_PARSE_DEPTH
567
+ if @_depth > MAX_PARSE_DEPTH
428
568
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
429
569
  end
430
570
 
@@ -433,16 +573,16 @@ module Cataract
433
573
 
434
574
  while pos < end_pos
435
575
  # Skip whitespace and comments
436
- while pos < end_pos && whitespace?(@css.getbyte(pos))
576
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
437
577
  pos += 1
438
578
  end
439
579
  break if pos >= end_pos
440
580
 
441
581
  # Skip comments
442
- if pos + 1 < end_pos && @css.getbyte(pos) == BYTE_SLASH && @css.getbyte(pos + 1) == BYTE_STAR
582
+ if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
443
583
  pos += 2
444
584
  while pos + 1 < end_pos
445
- if @css.getbyte(pos) == BYTE_STAR && @css.getbyte(pos + 1) == BYTE_SLASH
585
+ if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
446
586
  pos += 2
447
587
  break
448
588
  end
@@ -452,25 +592,25 @@ module Cataract
452
592
  end
453
593
 
454
594
  # Check if this is a nested @media query
455
- if @css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
595
+ if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
456
596
  byteslice_encoded(pos, 6) == '@media' &&
457
- (pos + 6 >= end_pos || whitespace?(@css.getbyte(pos + 6)))
597
+ (pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
458
598
  # Nested @media - parse with parent selector as context
459
599
  media_start = pos + 6
460
- while media_start < end_pos && whitespace?(@css.getbyte(media_start))
600
+ while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
461
601
  media_start += 1
462
602
  end
463
603
 
464
604
  # Find opening brace
465
605
  media_query_end = media_start
466
- while media_query_end < end_pos && @css.getbyte(media_query_end) != BYTE_LBRACE
606
+ while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
467
607
  media_query_end += 1
468
608
  end
469
609
  break if media_query_end >= end_pos
470
610
 
471
611
  # Extract media query (trim trailing whitespace)
472
612
  media_query_end_trimmed = media_query_end
473
- while media_query_end_trimmed > media_start && whitespace?(@css.getbyte(media_query_end_trimmed - 1))
613
+ while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
474
614
  media_query_end_trimmed -= 1
475
615
  end
476
616
  media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
@@ -489,15 +629,48 @@ module Cataract
489
629
  # Combine media queries: parent + child
490
630
  combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
491
631
 
632
+ # Create MediaQuery object for this nested @media
633
+ # If we're already in a media query context, combine with parent
634
+ nested_media_query_id = if parent_media_query_id
635
+ # Combine with parent MediaQuery
636
+ parent_mq = @media_queries[parent_media_query_id]
637
+
638
+ # This should never happen - parent_media_query_id should always be valid
639
+ if parent_mq.nil?
640
+ raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
641
+ end
642
+
643
+ # Combine parent media query with child
644
+ _child_type, child_conditions = parse_media_query_parts(media_query_str)
645
+ combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
646
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
647
+ @media_queries << combined_mq
648
+ combined_id = @_media_query_id_counter
649
+ @_media_query_id_counter += 1
650
+ combined_id
651
+ else
652
+ # No parent context, just use the child media query
653
+ media_type, media_conditions = parse_media_query_parts(media_query_str)
654
+ nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
655
+ @media_queries << nested_media_query
656
+ mq_id = @_media_query_id_counter
657
+ @_media_query_id_counter += 1
658
+ mq_id
659
+ end
660
+
492
661
  # Create rule ID for this media rule
493
- media_rule_id = @rule_id_counter
494
- @rule_id_counter += 1
662
+ media_rule_id = @_rule_id_counter
663
+ @_rule_id_counter += 1
664
+
665
+ # Reserve position in rules array (ensures sequential IDs match array indices)
666
+ rule_position = @rules.length
667
+ @rules << nil # Placeholder
495
668
 
496
- # Parse mixed block recursively
497
- @depth += 1
669
+ # Parse mixed block recursively with the nested media query ID as context
670
+ @_depth += 1
498
671
  media_declarations = parse_mixed_block(media_block_start, media_block_end,
499
- parent_selector, media_rule_id, combined_media_sym)
500
- @depth -= 1
672
+ parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
673
+ @_depth -= 1
501
674
 
502
675
  # Create rule with parent selector and declarations, associated with combined media query
503
676
  rule = Rule.new(
@@ -506,34 +679,34 @@ module Cataract
506
679
  media_declarations,
507
680
  nil, # specificity
508
681
  parent_rule_id,
509
- nil # nesting_style (nil for @media nesting)
682
+ nil, # nesting_style (nil for @media nesting)
683
+ nil, # selector_list_id
684
+ nested_media_query_id # media_query_id
510
685
  )
511
686
 
512
687
  # Mark that we have nesting
513
688
  @_has_nesting = true unless parent_rule_id.nil?
514
689
 
515
- @rules << rule
516
- @_media_index[combined_media_sym] ||= []
517
- @_media_index[combined_media_sym] << media_rule_id
518
-
690
+ # Replace placeholder with actual rule
691
+ @rules[rule_position] = rule
519
692
  next
520
693
  end
521
694
 
522
695
  # Check if this is a nested selector
523
- byte = @css.getbyte(pos)
696
+ byte = @_css.getbyte(pos)
524
697
  if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
525
698
  byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
526
699
  byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
527
700
  # Find the opening brace
528
701
  nested_sel_start = pos
529
- while pos < end_pos && @css.getbyte(pos) != BYTE_LBRACE
702
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
530
703
  pos += 1
531
704
  end
532
705
  break if pos >= end_pos
533
706
 
534
707
  nested_sel_end = pos
535
708
  # Trim trailing whitespace
536
- while nested_sel_end > nested_sel_start && whitespace?(@css.getbyte(nested_sel_end - 1))
709
+ while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
537
710
  nested_sel_end -= 1
538
711
  end
539
712
 
@@ -557,14 +730,18 @@ module Cataract
557
730
  resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
558
731
 
559
732
  # Get rule ID
560
- rule_id = @rule_id_counter
561
- @rule_id_counter += 1
733
+ rule_id = @_rule_id_counter
734
+ @_rule_id_counter += 1
735
+
736
+ # Reserve position in rules array (ensures sequential IDs match array indices)
737
+ rule_position = @rules.length
738
+ @rules << nil # Placeholder
562
739
 
563
740
  # Recursively parse nested block
564
- @depth += 1
741
+ @_depth += 1
565
742
  nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
566
- resolved_selector, rule_id, parent_media_sym)
567
- @depth -= 1
743
+ resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
744
+ @_depth -= 1
568
745
 
569
746
  # Create rule for nested selector
570
747
  rule = Rule.new(
@@ -579,9 +756,8 @@ module Cataract
579
756
  # Mark that we have nesting
580
757
  @_has_nesting = true unless parent_rule_id.nil?
581
758
 
582
- @rules << rule
583
- @_media_index[parent_media_sym] ||= [] if parent_media_sym
584
- @_media_index[parent_media_sym] << rule_id if parent_media_sym
759
+ # Replace placeholder with actual rule
760
+ @rules[rule_position] = rule
585
761
  end
586
762
 
587
763
  next
@@ -607,28 +783,40 @@ module Cataract
607
783
 
608
784
  # Check for closing brace
609
785
  if peek_byte == BYTE_RBRACE
610
- @pos += 1 # consume '}'
786
+ @_pos += 1 # consume '}'
611
787
  break
612
788
  end
613
789
 
614
790
  # Parse property name (read until ':')
615
- property_start = @pos
791
+ property_start = @_pos
616
792
  until eof?
617
793
  byte = peek_byte
618
794
  break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
619
795
 
620
- @pos += 1
796
+ @_pos += 1
621
797
  end
622
798
 
623
799
  # Skip if no colon found (malformed)
624
800
  if eof? || peek_byte != BYTE_COLON
801
+ # Check for malformed declaration (strict mode)
802
+ if @_check_malformed_declarations
803
+ property_text = byteslice_encoded(property_start, @_pos - property_start).strip
804
+ if property_text.empty?
805
+ raise ParseError.new('Malformed declaration: missing property name',
806
+ css: @_css, pos: property_start, type: :malformed_declaration)
807
+ else
808
+ raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
809
+ css: @_css, pos: property_start, type: :malformed_declaration)
810
+ end
811
+ end
812
+
625
813
  # Try to recover by finding next ; or }
626
814
  skip_to_semicolon_or_brace
627
815
  next
628
816
  end
629
817
 
630
818
  # Extract property name - use UTF-8 encoding to support custom properties with Unicode
631
- property = byteslice_encoded(property_start, @pos - property_start)
819
+ property = byteslice_encoded(property_start, @_pos - property_start)
632
820
  property.strip!
633
821
  # Custom properties (--foo) are case-sensitive and can contain Unicode
634
822
  # Regular properties are ASCII-only and case-insensitive
@@ -637,12 +825,12 @@ module Cataract
637
825
  property.force_encoding('US-ASCII')
638
826
  property.downcase!
639
827
  end
640
- @pos += 1 # skip ':'
828
+ @_pos += 1 # skip ':'
641
829
 
642
830
  skip_ws_and_comments
643
831
 
644
832
  # Parse value (read until ';' or '}', but respect quoted strings)
645
- value_start = @pos
833
+ value_start = @_pos
646
834
  important = false
647
835
  in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
648
836
 
@@ -653,9 +841,9 @@ module Cataract
653
841
  # Inside quoted string - only exit on matching quote
654
842
  if byte == in_quote
655
843
  in_quote = nil
656
- elsif byte == BYTE_BACKSLASH && @pos + 1 < @len
844
+ elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
657
845
  # Skip escaped character
658
- @pos += 1
846
+ @_pos += 1
659
847
  end
660
848
  else
661
849
  # Not in quote - check for terminators or quote start
@@ -666,14 +854,14 @@ module Cataract
666
854
  end
667
855
  end
668
856
 
669
- @pos += 1
857
+ @_pos += 1
670
858
  end
671
859
 
672
- value = byteslice_encoded(value_start, @pos - value_start)
860
+ value = byteslice_encoded(value_start, @_pos - value_start)
673
861
  value.strip!
674
862
 
675
863
  # Check for !important (byte-by-byte, no regexp)
676
- if value.bytesize > 10
864
+ if value.bytesize >= 10
677
865
  # Scan backwards to find !important
678
866
  i = value.bytesize - 1
679
867
  # Skip trailing whitespace
@@ -704,8 +892,17 @@ module Cataract
704
892
  end
705
893
  end
706
894
 
895
+ # Check for empty value (strict mode) - only if enabled to avoid overhead
896
+ if @_check_empty_values && value.empty?
897
+ raise ParseError.new("Empty value for property '#{property}'",
898
+ css: @_css, pos: property_start, type: :empty_value)
899
+ end
900
+
707
901
  # Skip semicolon if present
708
- @pos += 1 if peek_byte == BYTE_SEMICOLON
902
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
903
+
904
+ # Convert relative URLs to absolute if enabled
905
+ value = convert_urls_in_value(value)
709
906
 
710
907
  # Create Declaration struct
711
908
  declarations << Declaration.new(property, value, important)
@@ -717,35 +914,35 @@ module Cataract
717
914
  # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
718
915
  # Translated from C: see ext/cataract/css_parser.c lines 962-1128
719
916
  def parse_at_rule
720
- at_rule_start = @pos # Points to '@'
721
- @pos += 1 # skip '@'
917
+ at_rule_start = @_pos # Points to '@'
918
+ @_pos += 1 # skip '@'
722
919
 
723
920
  # Find end of at-rule name (stop at whitespace or opening brace)
724
- name_start = @pos
921
+ name_start = @_pos
725
922
  until eof?
726
923
  byte = peek_byte
727
924
  break if whitespace?(byte) || byte == BYTE_LBRACE
728
925
 
729
- @pos += 1
926
+ @_pos += 1
730
927
  end
731
928
 
732
- at_rule_name = byteslice_encoded(name_start, @pos - name_start)
929
+ at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
733
930
 
734
931
  # Handle @charset specially - it's just @charset "value";
735
932
  if at_rule_name == 'charset'
736
933
  skip_ws_and_comments
737
934
  # Read until semicolon
738
- value_start = @pos
935
+ value_start = @_pos
739
936
  while !eof? && peek_byte != BYTE_SEMICOLON
740
- @pos += 1
937
+ @_pos += 1
741
938
  end
742
939
 
743
- charset_value = byteslice_encoded(value_start, @pos - value_start)
940
+ charset_value = byteslice_encoded(value_start, @_pos - value_start)
744
941
  charset_value.strip!
745
942
  # Remove quotes
746
943
  @charset = charset_value.delete('"\'')
747
944
 
748
- @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
945
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
749
946
  return
750
947
  end
751
948
 
@@ -756,9 +953,9 @@ module Cataract
756
953
  warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
757
954
  # Skip to semicolon
758
955
  while !eof? && peek_byte != BYTE_SEMICOLON
759
- @pos += 1
956
+ @_pos += 1
760
957
  end
761
- @pos += 1 if peek_byte == BYTE_SEMICOLON
958
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
762
959
  return
763
960
  end
764
961
 
@@ -771,30 +968,44 @@ module Cataract
771
968
  if AT_RULE_TYPES.include?(at_rule_name)
772
969
  skip_ws_and_comments
773
970
 
971
+ # Remember start of condition for error reporting
972
+ condition_start = @_pos
973
+
774
974
  # Skip to opening brace
975
+ condition_end = @_pos
775
976
  while !eof? && peek_byte != BYTE_LBRACE
776
- @pos += 1
977
+ condition_end = @_pos
978
+ @_pos += 1
777
979
  end
778
980
 
779
981
  return if eof? || peek_byte != BYTE_LBRACE
780
982
 
781
- @pos += 1 # skip '{'
983
+ # Validate condition (strict mode) - @supports, @container, @scope require conditions
984
+ if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
985
+ condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
986
+ if condition_str.empty?
987
+ raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
988
+ css: @_css, pos: condition_start, type: :malformed_at_rule)
989
+ end
990
+ end
991
+
992
+ @_pos += 1 # skip '{'
782
993
 
783
994
  # Find matching closing brace
784
- block_start = @pos
785
- block_end = find_matching_brace(@pos)
995
+ block_start = @_pos
996
+ block_end = find_matching_brace(@_pos)
786
997
 
787
998
  # Check depth before recursing
788
- if @depth + 1 > MAX_PARSE_DEPTH
999
+ if @_depth + 1 > MAX_PARSE_DEPTH
789
1000
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
790
1001
  end
791
1002
 
792
1003
  # Recursively parse block content (preserve parent media context)
793
1004
  nested_parser = Parser.new(
794
1005
  byteslice_encoded(block_start, block_end - block_start),
795
- parser_options: @parser_options,
796
- parent_media_sym: @parent_media_sym,
797
- depth: @depth + 1
1006
+ parser_options: @_parser_options,
1007
+ parent_media_sym: @_parent_media_sym,
1008
+ depth: @_depth + 1
798
1009
  )
799
1010
 
800
1011
  nested_result = nested_parser.parse
@@ -804,33 +1015,29 @@ module Cataract
804
1015
  if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
805
1016
  nested_result[:_selector_lists].each do |list_id, rule_ids|
806
1017
  new_list_id = list_id + list_id_offset
807
- offsetted_rule_ids = rule_ids.map { |rid| rid + @rule_id_counter }
1018
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
808
1019
  @_selector_lists[new_list_id] = offsetted_rule_ids
809
1020
  end
810
1021
  @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
811
1022
  end
812
1023
 
813
- # Merge nested media_index into ours
814
- nested_result[:_media_index].each do |media, rule_ids|
815
- @_media_index[media] ||= []
816
- # Use each + << instead of concat + map (1.20x faster for small arrays)
817
- rule_ids.each { |rid| @_media_index[media] << (@rule_id_counter + rid) }
818
- end
1024
+ # NOTE: We no longer build media_index during parse
1025
+ # It will be built from MediaQuery objects after import resolution
819
1026
 
820
1027
  # Add nested rules to main rules array
821
1028
  nested_result[:rules].each do |rule|
822
- rule.id = @rule_id_counter
1029
+ rule.id = @_rule_id_counter
823
1030
  # Update selector_list_id if applicable
824
1031
  if rule.is_a?(Rule) && rule.selector_list_id
825
1032
  rule.selector_list_id += list_id_offset
826
1033
  end
827
- @rule_id_counter += 1
1034
+ @_rule_id_counter += 1
828
1035
  @rules << rule
829
1036
  end
830
1037
 
831
1038
  # Move position past the closing brace
832
- @pos = block_end
833
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1039
+ @_pos = block_end
1040
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
834
1041
 
835
1042
  return
836
1043
  end
@@ -840,52 +1047,90 @@ module Cataract
840
1047
  skip_ws_and_comments
841
1048
 
842
1049
  # Find media query (up to opening brace)
843
- mq_start = @pos
1050
+ mq_start = @_pos
844
1051
  while !eof? && peek_byte != BYTE_LBRACE
845
- @pos += 1
1052
+ @_pos += 1
846
1053
  end
847
1054
 
848
1055
  return if eof? || peek_byte != BYTE_LBRACE
849
1056
 
850
- mq_end = @pos
1057
+ mq_end = @_pos
851
1058
  # Trim trailing whitespace
852
- while mq_end > mq_start && whitespace?(@css.getbyte(mq_end - 1))
1059
+ while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
853
1060
  mq_end -= 1
854
1061
  end
855
1062
 
856
1063
  child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
857
1064
  # Keep media query exactly as written - parentheses are required per CSS spec
858
1065
  child_media_string.strip!
1066
+
1067
+ # Validate @media has a query (strict mode)
1068
+ if @_check_malformed_at_rules && child_media_string.empty?
1069
+ raise ParseError.new('Malformed @media: missing media query or condition',
1070
+ css: @_css, pos: mq_start, type: :malformed_at_rule)
1071
+ end
1072
+
859
1073
  child_media_sym = child_media_string.to_sym
860
1074
 
1075
+ # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1076
+ # Per W3C spec, comma acts as logical OR - each query is independent
1077
+ media_query_strings = child_media_string.split(',').map(&:strip)
1078
+
1079
+ # Create MediaQuery objects for each query in the list
1080
+ media_query_ids = []
1081
+ media_query_strings.each do |query_string|
1082
+ media_type, media_conditions = parse_media_query_parts(query_string)
1083
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1084
+ @media_queries << media_query
1085
+ media_query_ids << @_media_query_id_counter
1086
+ @_media_query_id_counter += 1
1087
+ end
1088
+
1089
+ # If multiple queries, track them as a list for serialization
1090
+ if media_query_ids.size > 1
1091
+ @_media_query_lists[@_next_media_query_list_id] = media_query_ids
1092
+ @_next_media_query_list_id += 1
1093
+ end
1094
+
1095
+ # Use first query ID as the primary one for rules in this block
1096
+ current_media_query_id = media_query_ids.first
1097
+
861
1098
  # Combine with parent media context
862
- combined_media_sym = combine_media_queries(@parent_media_sym, child_media_sym)
1099
+ combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1100
+
1101
+ # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
1102
+ # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
1103
+ # MediaQuery combining for nested @media happens in parse_mixed_block instead.
1104
+ # So this is just an alias to current_media_query_id.
1105
+ combined_media_query_id = current_media_query_id
863
1106
 
864
1107
  # Check media query limit
865
- unless @_media_index.key?(combined_media_sym)
866
- @media_query_count += 1
867
- if @media_query_count > MAX_MEDIA_QUERIES
1108
+ unless @media_index.key?(combined_media_sym)
1109
+ @_media_query_count += 1
1110
+ if @_media_query_count > MAX_MEDIA_QUERIES
868
1111
  raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
869
1112
  end
870
1113
  end
871
1114
 
872
- @pos += 1 # skip '{'
1115
+ @_pos += 1 # skip '{'
873
1116
 
874
1117
  # Find matching closing brace
875
- block_start = @pos
876
- block_end = find_matching_brace(@pos)
1118
+ block_start = @_pos
1119
+ block_end = find_matching_brace(@_pos)
877
1120
 
878
1121
  # Check depth before recursing
879
- if @depth + 1 > MAX_PARSE_DEPTH
1122
+ if @_depth + 1 > MAX_PARSE_DEPTH
880
1123
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
881
1124
  end
882
1125
 
883
1126
  # Parse the content with the combined media context
1127
+ # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
1128
+ # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
884
1129
  nested_parser = Parser.new(
885
1130
  byteslice_encoded(block_start, block_end - block_start),
886
- parser_options: @parser_options,
1131
+ parser_options: @_parser_options,
887
1132
  parent_media_sym: combined_media_sym,
888
- depth: @depth + 1
1133
+ depth: @_depth + 1
889
1134
  )
890
1135
 
891
1136
  nested_result = nested_parser.parse
@@ -895,50 +1140,84 @@ module Cataract
895
1140
  if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
896
1141
  nested_result[:_selector_lists].each do |list_id, rule_ids|
897
1142
  new_list_id = list_id + list_id_offset
898
- offsetted_rule_ids = rule_ids.map { |rid| rid + @rule_id_counter }
1143
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
899
1144
  @_selector_lists[new_list_id] = offsetted_rule_ids
900
1145
  end
901
1146
  @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
902
1147
  end
903
1148
 
904
- # Merge nested media_index into ours (for nested @media)
905
- nested_result[:_media_index].each do |media, rule_ids|
906
- @_media_index[media] ||= []
907
- # Use each + << instead of concat + map (1.20x faster for small arrays)
908
- rule_ids.each { |rid| @_media_index[media] << (@rule_id_counter + rid) }
1149
+ # Merge nested MediaQuery objects with offsetted IDs
1150
+ mq_id_offset = @_media_query_id_counter
1151
+ if nested_result[:media_queries] && !nested_result[:media_queries].empty?
1152
+ nested_result[:media_queries].each do |mq|
1153
+ # Create new MediaQuery with offsetted ID
1154
+ new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
1155
+ @media_queries << new_mq
1156
+ end
1157
+ @_media_query_id_counter += nested_result[:media_queries].size
1158
+ end
1159
+
1160
+ # Merge nested media_query_lists with offsetted IDs
1161
+ if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1162
+ nested_result[:_media_query_lists].each do |list_id, mq_ids|
1163
+ # Offset the list_id and media_query_ids
1164
+ new_list_id = list_id + @_next_media_query_list_id
1165
+ offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1166
+ @_media_query_lists[new_list_id] = offsetted_mq_ids
1167
+ end
1168
+ @_next_media_query_list_id += nested_result[:_media_query_lists].size
909
1169
  end
910
1170
 
911
- # Add nested rules to main rules array and update media_index
1171
+ # Merge nested media_index into ours (for nested @media)
1172
+ # Note: We no longer build media_index during parse
1173
+ # It will be built from MediaQuery objects after import resolution
1174
+
1175
+ # Add nested rules to main rules array
912
1176
  nested_result[:rules].each do |rule|
913
- rule.id = @rule_id_counter
1177
+ rule.id = @_rule_id_counter
914
1178
  # Update selector_list_id if applicable
915
1179
  if rule.is_a?(Rule) && rule.selector_list_id
916
1180
  rule.selector_list_id += list_id_offset
917
1181
  end
918
1182
 
919
- # Extract media types and add to each first (if different from full query)
920
- # We add these BEFORE the full query so that when iterating the media_index hash,
921
- # the full query comes last and takes precedence during serialization
922
- media_types = Cataract.parse_media_types(combined_media_sym)
923
- media_types.each do |media_type|
924
- # Only add if different from combined_media_sym to avoid duplication
925
- if media_type != combined_media_sym
926
- @_media_index[media_type] ||= []
927
- @_media_index[media_type] << @rule_id_counter
1183
+ # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1184
+ if rule.media_query_id
1185
+ # Nested parser assigned a media_query_id - need to combine with our context
1186
+ nested_mq_id = rule.media_query_id + mq_id_offset
1187
+ nested_mq = @media_queries[nested_mq_id]
1188
+
1189
+ # Combine nested media query with our media context
1190
+ if nested_mq && combined_media_query_id
1191
+ outer_mq = @media_queries[combined_media_query_id]
1192
+ if outer_mq
1193
+ # Combine media queries directly without string building
1194
+ combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
1195
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
1196
+ @media_queries << combined_mq
1197
+ rule.media_query_id = @_media_query_id_counter
1198
+ @_media_query_id_counter += 1
1199
+ else
1200
+ rule.media_query_id = nested_mq_id
1201
+ end
1202
+ else
1203
+ rule.media_query_id = nested_mq_id
928
1204
  end
1205
+ elsif rule.respond_to?(:media_query_id=)
1206
+ # Assign the combined media_query_id if no media_query_id set
1207
+ # (applies to both Rule and AtRule)
1208
+ rule.media_query_id = combined_media_query_id
929
1209
  end
930
1210
 
931
- # Add to full query symbol (after media types for insertion order)
932
- @_media_index[combined_media_sym] ||= []
933
- @_media_index[combined_media_sym] << @rule_id_counter
1211
+ # NOTE: We no longer build media_index during parse
1212
+ # It will be built from MediaQuery objects after import resolution
934
1213
 
935
- @rule_id_counter += 1
1214
+ @_rule_id_counter += 1
936
1215
  @rules << rule
937
1216
  end
938
1217
 
939
1218
  # Move position past the closing brace
940
- @pos = block_end
941
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1219
+ @_pos = block_end
1220
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
942
1221
 
943
1222
  return
944
1223
  end
@@ -954,26 +1233,26 @@ module Cataract
954
1233
 
955
1234
  # Skip to opening brace
956
1235
  while !eof? && peek_byte != BYTE_LBRACE
957
- @pos += 1
1236
+ @_pos += 1
958
1237
  end
959
1238
 
960
1239
  return if eof? || peek_byte != BYTE_LBRACE
961
1240
 
962
- selector_end = @pos
1241
+ selector_end = @_pos
963
1242
  # Trim trailing whitespace
964
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1243
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
965
1244
  selector_end -= 1
966
1245
  end
967
1246
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
968
1247
 
969
- @pos += 1 # skip '{'
1248
+ @_pos += 1 # skip '{'
970
1249
 
971
1250
  # Find matching closing brace
972
- block_start = @pos
973
- block_end = find_matching_brace(@pos)
1251
+ block_start = @_pos
1252
+ block_end = find_matching_brace(@_pos)
974
1253
 
975
1254
  # Check depth before recursing
976
- if @depth + 1 > MAX_PARSE_DEPTH
1255
+ if @_depth + 1 > MAX_PARSE_DEPTH
977
1256
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
978
1257
  end
979
1258
 
@@ -981,23 +1260,23 @@ module Cataract
981
1260
  # Create a nested parser context
982
1261
  nested_parser = Parser.new(
983
1262
  byteslice_encoded(block_start, block_end - block_start),
984
- parser_options: @parser_options,
985
- depth: @depth + 1
1263
+ parser_options: @_parser_options,
1264
+ depth: @_depth + 1
986
1265
  )
987
1266
  nested_result = nested_parser.parse
988
1267
  content = nested_result[:rules]
989
1268
 
990
1269
  # Move position past the closing brace
991
- @pos = block_end
1270
+ @_pos = block_end
992
1271
  # The closing brace should be at block_end
993
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1272
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
994
1273
 
995
1274
  # Get rule ID and increment
996
- rule_id = @rule_id_counter
997
- @rule_id_counter += 1
1275
+ rule_id = @_rule_id_counter
1276
+ @_rule_id_counter += 1
998
1277
 
999
1278
  # Create AtRule with nested rules
1000
- at_rule = AtRule.new(rule_id, selector, content, nil)
1279
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1001
1280
  @rules << at_rule
1002
1281
 
1003
1282
  return
@@ -1010,38 +1289,38 @@ module Cataract
1010
1289
 
1011
1290
  # Skip to opening brace
1012
1291
  while !eof? && peek_byte != BYTE_LBRACE
1013
- @pos += 1
1292
+ @_pos += 1
1014
1293
  end
1015
1294
 
1016
1295
  return if eof? || peek_byte != BYTE_LBRACE
1017
1296
 
1018
- selector_end = @pos
1297
+ selector_end = @_pos
1019
1298
  # Trim trailing whitespace
1020
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1299
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1021
1300
  selector_end -= 1
1022
1301
  end
1023
1302
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
1024
1303
 
1025
- @pos += 1 # skip '{'
1304
+ @_pos += 1 # skip '{'
1026
1305
 
1027
1306
  # Find matching closing brace
1028
- decl_start = @pos
1029
- decl_end = find_matching_brace(@pos)
1307
+ decl_start = @_pos
1308
+ decl_end = find_matching_brace(@_pos)
1030
1309
 
1031
1310
  # Parse declarations
1032
1311
  content = parse_declarations_block(decl_start, decl_end)
1033
1312
 
1034
1313
  # Move position past the closing brace
1035
- @pos = decl_end
1314
+ @_pos = decl_end
1036
1315
  # The closing brace should be at decl_end
1037
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1316
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1038
1317
 
1039
1318
  # Get rule ID and increment
1040
- rule_id = @rule_id_counter
1041
- @rule_id_counter += 1
1319
+ rule_id = @_rule_id_counter
1320
+ @_rule_id_counter += 1
1042
1321
 
1043
1322
  # Create AtRule with declarations
1044
- at_rule = AtRule.new(rule_id, selector, content, nil)
1323
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1045
1324
  @rules << at_rule
1046
1325
 
1047
1326
  return
@@ -1053,26 +1332,26 @@ module Cataract
1053
1332
 
1054
1333
  # Skip to opening brace
1055
1334
  until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
1056
- @pos += 1
1335
+ @_pos += 1
1057
1336
  end
1058
1337
 
1059
1338
  return if eof? || peek_byte != BYTE_LBRACE
1060
1339
 
1061
- selector_end = @pos
1340
+ selector_end = @_pos
1062
1341
  # Trim trailing whitespace
1063
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1342
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1064
1343
  selector_end -= 1
1065
1344
  end
1066
1345
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
1067
1346
 
1068
- @pos += 1 # skip '{'
1347
+ @_pos += 1 # skip '{'
1069
1348
 
1070
1349
  # Parse declarations
1071
1350
  declarations = parse_declarations
1072
1351
 
1073
1352
  # Create Rule with declarations
1074
1353
  rule = Rule.new(
1075
- @rule_id_counter, # id
1354
+ @_rule_id_counter, # id
1076
1355
  selector, # selector (e.g., "@property --main-color")
1077
1356
  declarations, # declarations
1078
1357
  nil, # specificity
@@ -1081,7 +1360,7 @@ module Cataract
1081
1360
  )
1082
1361
 
1083
1362
  @rules << rule
1084
- @rule_id_counter += 1
1363
+ @_rule_id_counter += 1
1085
1364
  end
1086
1365
 
1087
1366
  # Check if block contains nested selectors vs just declarations
@@ -1091,16 +1370,16 @@ module Cataract
1091
1370
 
1092
1371
  while pos < end_pos
1093
1372
  # Skip whitespace
1094
- while pos < end_pos && whitespace?(@css.getbyte(pos))
1373
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1095
1374
  pos += 1
1096
1375
  end
1097
1376
  break if pos >= end_pos
1098
1377
 
1099
1378
  # Skip comments
1100
- if pos + 1 < end_pos && @css.getbyte(pos) == BYTE_SLASH && @css.getbyte(pos + 1) == BYTE_STAR
1379
+ if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
1101
1380
  pos += 2
1102
1381
  while pos + 1 < end_pos
1103
- if @css.getbyte(pos) == BYTE_STAR && @css.getbyte(pos + 1) == BYTE_SLASH
1382
+ if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
1104
1383
  pos += 2
1105
1384
  break
1106
1385
  end
@@ -1110,24 +1389,24 @@ module Cataract
1110
1389
  end
1111
1390
 
1112
1391
  # Check for nested selector indicators
1113
- byte = @css.getbyte(pos)
1392
+ byte = @_css.getbyte(pos)
1114
1393
  if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
1115
1394
  byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
1116
1395
  byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
1117
1396
  # Look ahead - if followed by {, it's likely a nested selector
1118
1397
  lookahead = pos + 1
1119
- while lookahead < end_pos && @css.getbyte(lookahead) != BYTE_LBRACE &&
1120
- @css.getbyte(lookahead) != BYTE_SEMICOLON && @css.getbyte(lookahead) != BYTE_NEWLINE
1398
+ while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
1399
+ @_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
1121
1400
  lookahead += 1
1122
1401
  end
1123
- return true if lookahead < end_pos && @css.getbyte(lookahead) == BYTE_LBRACE
1402
+ return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
1124
1403
  end
1125
1404
 
1126
1405
  # Check for @media, @supports, etc nested inside
1127
1406
  return true if byte == BYTE_AT
1128
1407
 
1129
1408
  # Skip to next line or semicolon
1130
- while pos < end_pos && @css.getbyte(pos) != BYTE_SEMICOLON && @css.getbyte(pos) != BYTE_NEWLINE
1409
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
1131
1410
  pos += 1
1132
1411
  end
1133
1412
  pos += 1 if pos < end_pos
@@ -1263,10 +1542,10 @@ module Cataract
1263
1542
  # Skip to next semicolon or closing brace (error recovery)
1264
1543
  def skip_to_semicolon_or_brace
1265
1544
  until eof? || peek_byte == BYTE_SEMICOLON || peek_byte == BYTE_RBRACE # Flip to save a not_opt instruction: while !eof? && peek_byte != BYTE_SEMICOLON && peek_byte != BYTE_RBRACE
1266
- @pos += 1
1545
+ @_pos += 1
1267
1546
  end
1268
1547
 
1269
- @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1548
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1270
1549
  end
1271
1550
 
1272
1551
  # Parse an @import statement
@@ -1277,9 +1556,9 @@ module Cataract
1277
1556
 
1278
1557
  # Check for optional url(
1279
1558
  has_url_function = false
1280
- if @pos + 4 <= @len && match_ascii_ci?(@css, @pos, 'url(')
1559
+ if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
1281
1560
  has_url_function = true
1282
- @pos += 4
1561
+ @_pos += 4
1283
1562
  skip_ws_and_comments
1284
1563
  end
1285
1564
 
@@ -1288,24 +1567,24 @@ module Cataract
1288
1567
  if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
1289
1568
  # Invalid @import, skip to semicolon
1290
1569
  while !eof? && peek_byte != BYTE_SEMICOLON
1291
- @pos += 1
1570
+ @_pos += 1
1292
1571
  end
1293
- @pos += 1 unless eof?
1572
+ @_pos += 1 unless eof?
1294
1573
  return
1295
1574
  end
1296
1575
 
1297
1576
  quote_char = byte
1298
- @pos += 1 # Skip opening quote
1577
+ @_pos += 1 # Skip opening quote
1299
1578
 
1300
- url_start = @pos
1579
+ url_start = @_pos
1301
1580
 
1302
1581
  # Find closing quote (handle escaped quotes)
1303
1582
  while !eof? && peek_byte != quote_char
1304
- @pos += if peek_byte == BYTE_BACKSLASH && @pos + 1 < @len
1305
- 2 # Skip escaped character
1306
- else
1307
- 1
1308
- end
1583
+ @_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
1584
+ 2 # Skip escaped character
1585
+ else
1586
+ 1
1587
+ end
1309
1588
  end
1310
1589
 
1311
1590
  if eof?
@@ -1313,87 +1592,227 @@ module Cataract
1313
1592
  return
1314
1593
  end
1315
1594
 
1316
- url = byteslice_encoded(url_start, @pos - url_start)
1317
- @pos += 1 # Skip closing quote
1595
+ url = byteslice_encoded(url_start, @_pos - url_start)
1596
+ @_pos += 1 # Skip closing quote
1318
1597
 
1319
1598
  # Skip closing paren if we had url(
1320
1599
  if has_url_function
1321
1600
  skip_ws_and_comments
1322
- @pos += 1 if peek_byte == BYTE_RPAREN
1601
+ @_pos += 1 if peek_byte == BYTE_RPAREN
1323
1602
  end
1324
1603
 
1325
1604
  skip_ws_and_comments
1326
1605
 
1327
1606
  # Check for optional media query (everything until semicolon)
1328
- media = nil
1607
+ media_string = nil
1608
+ media_query_id = nil
1329
1609
  if !eof? && peek_byte != BYTE_SEMICOLON
1330
- media_start = @pos
1610
+ media_start = @_pos
1331
1611
 
1332
1612
  # Find semicolon
1333
1613
  while !eof? && peek_byte != BYTE_SEMICOLON
1334
- @pos += 1
1614
+ @_pos += 1
1335
1615
  end
1336
1616
 
1337
- media_end = @pos
1617
+ media_end = @_pos
1338
1618
 
1339
1619
  # Trim trailing whitespace from media query
1340
- while media_end > media_start && whitespace?(@css.getbyte(media_end - 1))
1620
+ while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
1341
1621
  media_end -= 1
1342
1622
  end
1343
1623
 
1344
1624
  if media_end > media_start
1345
- media = byteslice_encoded(media_start, media_end - media_start).to_sym
1625
+ media_string = byteslice_encoded(media_start, media_end - media_start)
1626
+
1627
+ # Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
1628
+ media_query_strings = media_string.split(',').map(&:strip)
1629
+
1630
+ # Create MediaQuery objects for each query in the list
1631
+ media_query_ids = []
1632
+ media_query_strings.each do |query_string|
1633
+ media_type, media_conditions = parse_media_query_parts(query_string)
1634
+
1635
+ # If we have a parent import's media context, combine them
1636
+ parent_import_type = @_parser_options[:parent_import_media_type]
1637
+ parent_import_conditions = @_parser_options[:parent_import_media_conditions]
1638
+
1639
+ if parent_import_type
1640
+ # Combine: parent's type is the effective type
1641
+ # Conditions are combined with "and"
1642
+ combined_type = parent_import_type
1643
+ combined_conditions = if parent_import_conditions && media_conditions
1644
+ "#{parent_import_conditions} and #{media_conditions}"
1645
+ elsif parent_import_conditions
1646
+ "#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
1647
+ elsif media_conditions
1648
+ media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
1649
+ else
1650
+ media_type == parent_import_type ? nil : media_type.to_s
1651
+ end
1652
+
1653
+ media_type = combined_type
1654
+ media_conditions = combined_conditions
1655
+ end
1656
+
1657
+ # Create MediaQuery object
1658
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1659
+ @media_queries << media_query
1660
+ media_query_ids << @_media_query_id_counter
1661
+ @_media_query_id_counter += 1
1662
+ end
1663
+
1664
+ # Use the first media query ID for the import statement
1665
+ # (The list is tracked separately for serialization)
1666
+ media_query_id = media_query_ids.first
1667
+
1668
+ # If multiple queries, track them as a list for serialization
1669
+ if media_query_ids.size > 1
1670
+ media_query_list_id = @_next_media_query_list_id
1671
+ @_media_query_lists[media_query_list_id] = media_query_ids
1672
+ @_next_media_query_list_id += 1
1673
+ end
1346
1674
  end
1347
1675
  end
1348
1676
 
1349
1677
  # Skip semicolon
1350
- @pos += 1 if peek_byte == BYTE_SEMICOLON
1678
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
1351
1679
 
1352
1680
  # Create ImportStatement (resolved: false by default)
1353
- import_stmt = ImportStatement.new(@rule_id_counter, url, media, false)
1681
+ import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
1354
1682
  @imports << import_stmt
1355
- @rule_id_counter += 1
1683
+ @_rule_id_counter += 1
1356
1684
  end
1357
1685
 
1358
- # Skip @import statements at the beginning of CSS (DEPRECATED - now parsed)
1359
- # Per CSS spec, @import must come before all rules (except @charset)
1360
- def skip_imports
1361
- until eof?
1362
- # Skip whitespace
1363
- while !eof? && whitespace?(peek_byte)
1364
- @pos += 1
1365
- end
1366
- break if eof?
1686
+ # Convert relative URLs in a value string to absolute URLs
1687
+ # Called when @_absolute_paths is enabled and @_base_uri is set
1688
+ #
1689
+ # @param value [String] The declaration value to process
1690
+ # @return [String] Value with relative URLs converted to absolute
1691
+ def convert_urls_in_value(value)
1692
+ return value unless @_absolute_paths && @_base_uri
1693
+
1694
+ result = +''
1695
+ pos = 0
1696
+ len = value.bytesize
1697
+
1698
+ while pos < len
1699
+ # Look for 'url(' - case insensitive
1700
+ byte = value.getbyte(pos)
1701
+ if pos + 3 < len &&
1702
+ (byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
1703
+ (value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
1704
+ (value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
1705
+ value.getbyte(pos + 3) == BYTE_LPAREN
1706
+
1707
+ result << value.byteslice(pos, 4) # append 'url('
1708
+ pos += 4
1709
+
1710
+ # Skip whitespace
1711
+ while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1712
+ result << value.getbyte(pos).chr
1713
+ pos += 1
1714
+ end
1367
1715
 
1368
- # Skip comments
1369
- if @pos + 1 < @len && @css.getbyte(@pos) == BYTE_SLASH && @css.getbyte(@pos + 1) == BYTE_STAR
1370
- @pos += 2
1371
- while @pos + 1 < @len
1372
- if @css.getbyte(@pos) == BYTE_STAR && @css.getbyte(@pos + 1) == BYTE_SLASH
1373
- @pos += 2
1374
- break
1716
+ # Check for quote
1717
+ quote_char = nil
1718
+ if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
1719
+ quote_char = value.getbyte(pos)
1720
+ pos += 1
1721
+ end
1722
+
1723
+ # Extract URL
1724
+ url_start = pos
1725
+ if quote_char
1726
+ # Scan until matching quote
1727
+ while pos < len && value.getbyte(pos) != quote_char
1728
+ # Handle escape
1729
+ pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
1730
+ 2
1731
+ else
1732
+ 1
1733
+ end
1734
+ end
1735
+ else
1736
+ # Scan until ) or whitespace
1737
+ while pos < len
1738
+ b = value.getbyte(pos)
1739
+ break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
1740
+
1741
+ pos += 1
1375
1742
  end
1376
- @pos += 1
1377
1743
  end
1378
- next
1379
- end
1380
1744
 
1381
- # Check for @import (case-insensitive byte comparison)
1382
- if @pos + 7 <= @len && @css.getbyte(@pos) == BYTE_AT && match_ascii_ci?(@css, @pos + 1, 'import')
1383
- # Check that it's followed by whitespace or quote
1384
- if @pos + 7 >= @len || whitespace?(@css.getbyte(@pos + 7)) || @css.getbyte(@pos + 7) == BYTE_SQUOTE || @css.getbyte(@pos + 7) == BYTE_DQUOTE
1385
- # Skip to semicolon
1386
- while !eof? && peek_byte != BYTE_SEMICOLON
1387
- @pos += 1
1745
+ url_str = value.byteslice(url_start, pos - url_start)
1746
+
1747
+ # Check if URL needs resolution (is relative)
1748
+ # Skip if: contains "://" OR starts with "data:"
1749
+ needs_resolution = true
1750
+ if url_str.empty?
1751
+ needs_resolution = false
1752
+ else
1753
+ # Check for "://"
1754
+ i = 0
1755
+ url_len = url_str.bytesize
1756
+ while i + 2 < url_len
1757
+ if url_str.getbyte(i) == BYTE_COLON &&
1758
+ url_str.getbyte(i + 1) == BYTE_SLASH &&
1759
+ url_str.getbyte(i + 2) == BYTE_SLASH
1760
+ needs_resolution = false
1761
+ break
1762
+ end
1763
+ i += 1
1764
+ end
1765
+
1766
+ # Check for "data:" prefix (case insensitive)
1767
+ if needs_resolution && url_len >= 5
1768
+ if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
1769
+ (url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
1770
+ (url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
1771
+ (url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
1772
+ url_str.getbyte(4) == BYTE_COLON
1773
+ needs_resolution = false
1774
+ end
1388
1775
  end
1389
- @pos += 1 unless eof? # Skip semicolon
1390
- next
1391
1776
  end
1392
- end
1393
1777
 
1394
- # Hit non-@import content, stop skipping
1395
- break
1778
+ if needs_resolution
1779
+ # Resolve relative URL using the resolver proc
1780
+ begin
1781
+ resolved = @_uri_resolver.call(@_base_uri, url_str)
1782
+ result << "'" << resolved << "'"
1783
+ rescue StandardError
1784
+ # If resolution fails, preserve original
1785
+ if quote_char
1786
+ result << quote_char.chr << url_str << quote_char.chr
1787
+ else
1788
+ result << url_str
1789
+ end
1790
+ end
1791
+ elsif url_str.empty?
1792
+ # Preserve original URL
1793
+ result << "''"
1794
+ elsif quote_char
1795
+ result << quote_char.chr << url_str << quote_char.chr
1796
+ else
1797
+ result << url_str
1798
+ end
1799
+
1800
+ # Skip past closing quote if present
1801
+ pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
1802
+
1803
+ # Skip whitespace before )
1804
+ while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1805
+ pos += 1
1806
+ end
1807
+
1808
+ # The ) will be copied in the next iteration or at the end
1809
+ else
1810
+ result << byte.chr
1811
+ pos += 1
1812
+ end
1396
1813
  end
1814
+
1815
+ result
1397
1816
  end
1398
1817
 
1399
1818
  # Parse a block of declarations given start/end positions
@@ -1405,7 +1824,7 @@ module Cataract
1405
1824
 
1406
1825
  while pos < end_pos
1407
1826
  # Skip whitespace
1408
- while pos < end_pos && whitespace?(@css.getbyte(pos))
1827
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1409
1828
  pos += 1
1410
1829
  end
1411
1830
  break if pos >= end_pos
@@ -1417,5 +1836,115 @@ module Cataract
1417
1836
 
1418
1837
  declarations
1419
1838
  end
1839
+
1840
+ # Combine parent and child media query parts directly without string building
1841
+ #
1842
+ # The parent's type takes precedence (child type is ignored per CSS spec).
1843
+ #
1844
+ # @param parent_mq [MediaQuery] Parent media query object
1845
+ # @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
1846
+ # @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
1847
+ #
1848
+ # @example
1849
+ # combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
1850
+ def combine_media_query_parts(parent_mq, child_conditions)
1851
+ # Type: parent's type wins (outermost type)
1852
+ combined_type = parent_mq.type
1853
+
1854
+ # Conditions: combine parent and child conditions
1855
+ combined_conditions = if parent_mq.conditions && child_conditions
1856
+ "#{parent_mq.conditions} and #{child_conditions}"
1857
+ elsif parent_mq.conditions
1858
+ parent_mq.conditions
1859
+ elsif child_conditions
1860
+ child_conditions
1861
+ end
1862
+
1863
+ [combined_type, combined_conditions]
1864
+ end
1865
+
1866
+ # Parse media query string into type and conditions
1867
+ #
1868
+ # @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
1869
+ # @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
1870
+ #
1871
+ # @example
1872
+ # parse_media_query_parts("screen") #=> [:screen, nil]
1873
+ # parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
1874
+ # parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
1875
+ def parse_media_query_parts(query)
1876
+ i = 0
1877
+ len = query.bytesize
1878
+
1879
+ # Skip leading whitespace
1880
+ while i < len && whitespace?(query.getbyte(i))
1881
+ i += 1
1882
+ end
1883
+
1884
+ return [:all, nil] if i >= len
1885
+
1886
+ # Check if starts with '(' - media feature without type (defaults to :all)
1887
+ if query.getbyte(i) == BYTE_LPAREN
1888
+ return [:all, query.byteslice(i, len - i)]
1889
+ end
1890
+
1891
+ # Find first media type word
1892
+ word_start = i
1893
+ while i < len
1894
+ byte = query.getbyte(i)
1895
+ break if whitespace?(byte) || byte == BYTE_LPAREN
1896
+
1897
+ i += 1
1898
+ end
1899
+
1900
+ type = query.byteslice(word_start, i - word_start).to_sym
1901
+
1902
+ # Skip whitespace after type
1903
+ while i < len && whitespace?(query.getbyte(i))
1904
+ i += 1
1905
+ end
1906
+
1907
+ # Check if there's more (conditions)
1908
+ if i >= len
1909
+ return [type, nil]
1910
+ end
1911
+
1912
+ # Look for " and " keyword (case-insensitive)
1913
+ # We need to find "and" as a separate word
1914
+ and_pos = nil
1915
+ check_i = i
1916
+ while check_i < len - 2
1917
+ # Check for 'and' (a=97/65, n=110/78, d=100/68)
1918
+ byte0 = query.getbyte(check_i)
1919
+ byte1 = query.getbyte(check_i + 1)
1920
+ byte2 = query.getbyte(check_i + 2)
1921
+
1922
+ if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
1923
+ (byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
1924
+ (byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
1925
+ # Make sure it's a word boundary (whitespace before and after)
1926
+ before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
1927
+ after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
1928
+ if before_ok && after_ok
1929
+ and_pos = check_i
1930
+ break
1931
+ end
1932
+ end
1933
+ check_i += 1
1934
+ end
1935
+
1936
+ if and_pos
1937
+ # Skip past "and " to get conditions
1938
+ conditions_start = and_pos + 3 # skip "and"
1939
+ while conditions_start < len && whitespace?(query.getbyte(conditions_start))
1940
+ conditions_start += 1
1941
+ end
1942
+ conditions = query.byteslice(conditions_start, len - conditions_start)
1943
+ [type, conditions]
1944
+ else
1945
+ # No "and" found - rest is conditions (unusual but possible)
1946
+ [type, query.byteslice(i, len - i)]
1947
+ end
1948
+ end
1420
1949
  end
1421
1950
  end