cataract 0.2.3 → 0.2.4

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,47 @@ 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
+ # Private: Internal parsing state
68
+ @_css = css_string.dup.freeze
69
+ @_pos = 0
70
+ @_len = @_css.bytesize
71
+ @_parent_media_sym = parent_media_sym
72
+ @_parent_media_query_id = parent_media_query_id
73
+ @_depth = depth # Current recursion depth (passed from parent parser)
74
+
75
+ # Private: Parser options with defaults
76
+ @_parser_options = {
77
+ selector_lists: true,
78
+ base_uri: nil,
79
+ absolute_paths: false,
80
+ uri_resolver: nil
77
81
  }.merge(parser_options)
78
82
 
79
- # Extract selector_lists option to ivar to avoid repeated hash lookups in hot path
80
- @selector_lists_enabled = @parser_options[:selector_lists]
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
81
88
 
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
89
+ # Private: Internal counters
90
+ @_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
86
91
  @_next_selector_list_id = 0 # Counter for selector list IDs
92
+ @_next_media_query_list_id = 0 # Counter for media query list IDs
93
+ @_rule_id_counter = 0 # Next rule ID (0-indexed)
94
+ @_media_query_count = 0 # Safety limit
95
+
96
+ # Public: Parser results (returned in parse result hash)
97
+ @rules = [] # Flat array of Rule structs
98
+ @media_queries = [] # Array of MediaQuery objects
99
+ @media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
87
100
  @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
101
  @charset = nil # @charset declaration
102
+
103
+ # Semi-private: Internal state exposed with _ prefix in result
104
+ @_selector_lists = {} # Hash: list_id => Array of rule IDs
105
+ @_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
106
+ @_has_nesting = false # Set to true if any nested rules found
93
107
  end
94
108
 
95
109
  def parse
@@ -118,7 +132,7 @@ module Cataract
118
132
  end
119
133
 
120
134
  # Find the block boundaries
121
- decl_start = @pos # Should be right after the {
135
+ decl_start = @_pos # Should be right after the {
122
136
  decl_end = find_matching_brace(decl_start)
123
137
 
124
138
  # Check if block has nested selectors
@@ -132,18 +146,18 @@ module Cataract
132
146
  next if individual_selector.empty?
133
147
 
134
148
  # Get rule ID for this selector
135
- current_rule_id = @rule_id_counter
136
- @rule_id_counter += 1
149
+ current_rule_id = @_rule_id_counter
150
+ @_rule_id_counter += 1
137
151
 
138
152
  # Reserve parent's position in rules array (ensures parent comes before nested)
139
153
  parent_position = @rules.length
140
154
  @rules << nil # Placeholder
141
155
 
142
156
  # Parse mixed block (declarations + nested selectors)
143
- @depth += 1
157
+ @_depth += 1
144
158
  parent_declarations = parse_mixed_block(decl_start, decl_end,
145
- individual_selector, current_rule_id, @parent_media_sym)
146
- @depth -= 1
159
+ individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
160
+ @_depth -= 1
147
161
 
148
162
  # Create parent rule and replace placeholder
149
163
  rule = Rule.new(
@@ -156,16 +170,14 @@ module Cataract
156
170
  )
157
171
 
158
172
  @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
173
  end
162
174
 
163
175
  # Move position past the closing brace
164
- @pos = decl_end
165
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
176
+ @_pos = decl_end
177
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
166
178
  else
167
179
  # NON-NESTED PATH: Parse declarations only
168
- @pos = decl_start # Reset to start of block
180
+ @_pos = decl_start # Reset to start of block
169
181
  declarations = parse_declarations
170
182
 
171
183
  # Split comma-separated selectors into individual rules
@@ -174,7 +186,7 @@ module Cataract
174
186
  # Determine if we should track this as a selector list
175
187
  # Check boolean first to potentially avoid size() call via short-circuit evaluation
176
188
  list_id = nil
177
- if @selector_lists_enabled && selectors.size > 1
189
+ if @_selector_lists_enabled && selectors.size > 1
178
190
  list_id = @_next_selector_list_id
179
191
  @_next_selector_list_id += 1
180
192
  @_selector_lists[list_id] = []
@@ -184,7 +196,7 @@ module Cataract
184
196
  individual_selector.strip!
185
197
  next if individual_selector.empty?
186
198
 
187
- rule_id = @rule_id_counter
199
+ rule_id = @_rule_id_counter
188
200
 
189
201
  # Dup declarations for each rule in a selector list to avoid shared state
190
202
  # (principle of least surprise - modifying one rule shouldn't affect others)
@@ -207,7 +219,7 @@ module Cataract
207
219
  )
208
220
 
209
221
  @rules << rule
210
- @rule_id_counter += 1
222
+ @_rule_id_counter += 1
211
223
 
212
224
  # Track in selector list if applicable
213
225
  @_selector_lists[list_id] << rule_id if list_id
@@ -217,8 +229,10 @@ module Cataract
217
229
 
218
230
  {
219
231
  rules: @rules,
220
- _media_index: @_media_index,
232
+ _media_index: @media_index,
233
+ media_queries: @media_queries,
221
234
  _selector_lists: @_selector_lists,
235
+ _media_query_lists: @_media_query_lists,
222
236
  imports: @imports,
223
237
  charset: @charset,
224
238
  _has_nesting: @_has_nesting
@@ -229,7 +243,7 @@ module Cataract
229
243
 
230
244
  # Check if we're at end of input
231
245
  def eof?
232
- @pos >= @len
246
+ @_pos >= @_len
233
247
  end
234
248
 
235
249
  # Peek current byte without advancing
@@ -237,7 +251,7 @@ module Cataract
237
251
  def peek_byte
238
252
  return nil if eof?
239
253
 
240
- @css.getbyte(@pos)
254
+ @_css.getbyte(@_pos)
241
255
  end
242
256
 
243
257
  # Delegate to module-level helper methods (now work with bytes)
@@ -258,19 +272,19 @@ module Cataract
258
272
  end
259
273
 
260
274
  def skip_whitespace
261
- @pos += 1 while !eof? && whitespace?(peek_byte)
275
+ @_pos += 1 while !eof? && whitespace?(peek_byte)
262
276
  end
263
277
 
264
278
  def skip_comment # rubocop:disable Naming/PredicateMethod
265
- return false unless peek_byte == BYTE_SLASH && @css.getbyte(@pos + 1) == BYTE_STAR
279
+ return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
266
280
 
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 */
281
+ @_pos += 2 # Skip /*
282
+ while @_pos + 1 < @_len
283
+ if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
284
+ @_pos += 2 # Skip */
271
285
  return true
272
286
  end
273
- @pos += 1
287
+ @_pos += 1
274
288
  end
275
289
  true
276
290
  end
@@ -283,10 +297,10 @@ module Cataract
283
297
  # Benchmark shows 15-51% speedup depending on YJIT
284
298
  def skip_ws_and_comments
285
299
  begin
286
- old_pos = @pos
300
+ old_pos = @_pos
287
301
  skip_whitespace
288
302
  skip_comment
289
- end until @pos == old_pos # No progress made # rubocop:disable Lint/Loop
303
+ end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
290
304
  end
291
305
 
292
306
  # Parse a single CSS declaration (property: value)
@@ -301,24 +315,24 @@ module Cataract
301
315
  def parse_single_declaration(pos, end_pos, parse_important)
302
316
  # Parse property name (scan until ':')
303
317
  prop_start = pos
304
- while pos < end_pos && @css.getbyte(pos) != BYTE_COLON &&
305
- @css.getbyte(pos) != BYTE_SEMICOLON && @css.getbyte(pos) != BYTE_RBRACE
318
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
319
+ @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
306
320
  pos += 1
307
321
  end
308
322
 
309
323
  # Skip if malformed (no colon found)
310
- if pos >= end_pos || @css.getbyte(pos) != BYTE_COLON
324
+ if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
311
325
  # Error recovery: skip to next semicolon
312
- while pos < end_pos && @css.getbyte(pos) != BYTE_SEMICOLON
326
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
313
327
  pos += 1
314
328
  end
315
- pos += 1 if pos < end_pos && @css.getbyte(pos) == BYTE_SEMICOLON
329
+ pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
316
330
  return [nil, pos]
317
331
  end
318
332
 
319
333
  # Trim trailing whitespace from property
320
334
  prop_end = pos
321
- while prop_end > prop_start && whitespace?(@css.getbyte(prop_end - 1))
335
+ while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
322
336
  prop_end -= 1
323
337
  end
324
338
 
@@ -334,19 +348,19 @@ module Cataract
334
348
  pos += 1 # Skip ':'
335
349
 
336
350
  # Skip leading whitespace in value
337
- while pos < end_pos && whitespace?(@css.getbyte(pos))
351
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
338
352
  pos += 1
339
353
  end
340
354
 
341
355
  # Parse value (scan until ';' or '}')
342
356
  val_start = pos
343
- while pos < end_pos && @css.getbyte(pos) != BYTE_SEMICOLON && @css.getbyte(pos) != BYTE_RBRACE
357
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
344
358
  pos += 1
345
359
  end
346
360
  val_end = pos
347
361
 
348
362
  # Trim trailing whitespace from value
349
- while val_end > val_start && whitespace?(@css.getbyte(val_end - 1))
363
+ while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
350
364
  val_end -= 1
351
365
  end
352
366
 
@@ -361,11 +375,14 @@ module Cataract
361
375
  end
362
376
 
363
377
  # Skip semicolon if present
364
- pos += 1 if pos < end_pos && @css.getbyte(pos) == BYTE_SEMICOLON
378
+ pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
365
379
 
366
380
  # Return nil if empty declaration
367
381
  return [nil, pos] if prop_end <= prop_start || val_end <= val_start
368
382
 
383
+ # Convert relative URLs to absolute if enabled
384
+ value = convert_urls_in_value(value)
385
+
369
386
  [Declaration.new(property, value, important), pos]
370
387
  end
371
388
 
@@ -382,8 +399,8 @@ module Cataract
382
399
  depth = 1
383
400
  pos = start_pos
384
401
 
385
- while pos < @len
386
- byte = @css.getbyte(pos)
402
+ while pos < @_len
403
+ byte = @_css.getbyte(pos)
387
404
  if byte == BYTE_RBRACE
388
405
  depth -= 1
389
406
  return pos if depth == 0
@@ -398,21 +415,21 @@ module Cataract
398
415
 
399
416
  # Parse selector (read until '{')
400
417
  def parse_selector
401
- start_pos = @pos
418
+ start_pos = @_pos
402
419
 
403
420
  # Read until we find '{'
404
421
  until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
405
- @pos += 1
422
+ @_pos += 1
406
423
  end
407
424
 
408
425
  # If we hit EOF without finding '{', return nil
409
426
  return nil if eof?
410
427
 
411
428
  # Extract selector text
412
- selector_text = byteslice_encoded(start_pos, @pos - start_pos)
429
+ selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
413
430
 
414
431
  # Skip the '{'
415
- @pos += 1 if peek_byte == BYTE_LBRACE
432
+ @_pos += 1 if peek_byte == BYTE_LBRACE
416
433
 
417
434
  # Trim whitespace from selector (in-place to avoid allocation)
418
435
  selector_text.strip!
@@ -422,9 +439,9 @@ module Cataract
422
439
  # Parse mixed block containing declarations AND nested selectors/at-rules
423
440
  # Translated from C: see ext/cataract/css_parser.c parse_mixed_block
424
441
  # 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)
442
+ def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
426
443
  # Check recursion depth to prevent stack overflow
427
- if @depth > MAX_PARSE_DEPTH
444
+ if @_depth > MAX_PARSE_DEPTH
428
445
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
429
446
  end
430
447
 
@@ -433,16 +450,16 @@ module Cataract
433
450
 
434
451
  while pos < end_pos
435
452
  # Skip whitespace and comments
436
- while pos < end_pos && whitespace?(@css.getbyte(pos))
453
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
437
454
  pos += 1
438
455
  end
439
456
  break if pos >= end_pos
440
457
 
441
458
  # Skip comments
442
- if pos + 1 < end_pos && @css.getbyte(pos) == BYTE_SLASH && @css.getbyte(pos + 1) == BYTE_STAR
459
+ if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
443
460
  pos += 2
444
461
  while pos + 1 < end_pos
445
- if @css.getbyte(pos) == BYTE_STAR && @css.getbyte(pos + 1) == BYTE_SLASH
462
+ if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
446
463
  pos += 2
447
464
  break
448
465
  end
@@ -452,25 +469,25 @@ module Cataract
452
469
  end
453
470
 
454
471
  # Check if this is a nested @media query
455
- if @css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
472
+ if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
456
473
  byteslice_encoded(pos, 6) == '@media' &&
457
- (pos + 6 >= end_pos || whitespace?(@css.getbyte(pos + 6)))
474
+ (pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
458
475
  # Nested @media - parse with parent selector as context
459
476
  media_start = pos + 6
460
- while media_start < end_pos && whitespace?(@css.getbyte(media_start))
477
+ while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
461
478
  media_start += 1
462
479
  end
463
480
 
464
481
  # Find opening brace
465
482
  media_query_end = media_start
466
- while media_query_end < end_pos && @css.getbyte(media_query_end) != BYTE_LBRACE
483
+ while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
467
484
  media_query_end += 1
468
485
  end
469
486
  break if media_query_end >= end_pos
470
487
 
471
488
  # Extract media query (trim trailing whitespace)
472
489
  media_query_end_trimmed = media_query_end
473
- while media_query_end_trimmed > media_start && whitespace?(@css.getbyte(media_query_end_trimmed - 1))
490
+ while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
474
491
  media_query_end_trimmed -= 1
475
492
  end
476
493
  media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
@@ -489,15 +506,48 @@ module Cataract
489
506
  # Combine media queries: parent + child
490
507
  combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
491
508
 
509
+ # Create MediaQuery object for this nested @media
510
+ # If we're already in a media query context, combine with parent
511
+ nested_media_query_id = if parent_media_query_id
512
+ # Combine with parent MediaQuery
513
+ parent_mq = @media_queries[parent_media_query_id]
514
+
515
+ # This should never happen - parent_media_query_id should always be valid
516
+ if parent_mq.nil?
517
+ raise ParserError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
518
+ end
519
+
520
+ # Combine parent media query with child
521
+ _child_type, child_conditions = parse_media_query_parts(media_query_str)
522
+ combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
523
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
524
+ @media_queries << combined_mq
525
+ combined_id = @_media_query_id_counter
526
+ @_media_query_id_counter += 1
527
+ combined_id
528
+ else
529
+ # No parent context, just use the child media query
530
+ media_type, media_conditions = parse_media_query_parts(media_query_str)
531
+ nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
532
+ @media_queries << nested_media_query
533
+ mq_id = @_media_query_id_counter
534
+ @_media_query_id_counter += 1
535
+ mq_id
536
+ end
537
+
492
538
  # Create rule ID for this media rule
493
- media_rule_id = @rule_id_counter
494
- @rule_id_counter += 1
539
+ media_rule_id = @_rule_id_counter
540
+ @_rule_id_counter += 1
541
+
542
+ # Reserve position in rules array (ensures sequential IDs match array indices)
543
+ rule_position = @rules.length
544
+ @rules << nil # Placeholder
495
545
 
496
- # Parse mixed block recursively
497
- @depth += 1
546
+ # Parse mixed block recursively with the nested media query ID as context
547
+ @_depth += 1
498
548
  media_declarations = parse_mixed_block(media_block_start, media_block_end,
499
- parent_selector, media_rule_id, combined_media_sym)
500
- @depth -= 1
549
+ parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
550
+ @_depth -= 1
501
551
 
502
552
  # Create rule with parent selector and declarations, associated with combined media query
503
553
  rule = Rule.new(
@@ -506,34 +556,34 @@ module Cataract
506
556
  media_declarations,
507
557
  nil, # specificity
508
558
  parent_rule_id,
509
- nil # nesting_style (nil for @media nesting)
559
+ nil, # nesting_style (nil for @media nesting)
560
+ nil, # selector_list_id
561
+ nested_media_query_id # media_query_id
510
562
  )
511
563
 
512
564
  # Mark that we have nesting
513
565
  @_has_nesting = true unless parent_rule_id.nil?
514
566
 
515
- @rules << rule
516
- @_media_index[combined_media_sym] ||= []
517
- @_media_index[combined_media_sym] << media_rule_id
518
-
567
+ # Replace placeholder with actual rule
568
+ @rules[rule_position] = rule
519
569
  next
520
570
  end
521
571
 
522
572
  # Check if this is a nested selector
523
- byte = @css.getbyte(pos)
573
+ byte = @_css.getbyte(pos)
524
574
  if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
525
575
  byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
526
576
  byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
527
577
  # Find the opening brace
528
578
  nested_sel_start = pos
529
- while pos < end_pos && @css.getbyte(pos) != BYTE_LBRACE
579
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
530
580
  pos += 1
531
581
  end
532
582
  break if pos >= end_pos
533
583
 
534
584
  nested_sel_end = pos
535
585
  # Trim trailing whitespace
536
- while nested_sel_end > nested_sel_start && whitespace?(@css.getbyte(nested_sel_end - 1))
586
+ while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
537
587
  nested_sel_end -= 1
538
588
  end
539
589
 
@@ -557,14 +607,18 @@ module Cataract
557
607
  resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
558
608
 
559
609
  # Get rule ID
560
- rule_id = @rule_id_counter
561
- @rule_id_counter += 1
610
+ rule_id = @_rule_id_counter
611
+ @_rule_id_counter += 1
612
+
613
+ # Reserve position in rules array (ensures sequential IDs match array indices)
614
+ rule_position = @rules.length
615
+ @rules << nil # Placeholder
562
616
 
563
617
  # Recursively parse nested block
564
- @depth += 1
618
+ @_depth += 1
565
619
  nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
566
- resolved_selector, rule_id, parent_media_sym)
567
- @depth -= 1
620
+ resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
621
+ @_depth -= 1
568
622
 
569
623
  # Create rule for nested selector
570
624
  rule = Rule.new(
@@ -579,9 +633,8 @@ module Cataract
579
633
  # Mark that we have nesting
580
634
  @_has_nesting = true unless parent_rule_id.nil?
581
635
 
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
636
+ # Replace placeholder with actual rule
637
+ @rules[rule_position] = rule
585
638
  end
586
639
 
587
640
  next
@@ -607,17 +660,17 @@ module Cataract
607
660
 
608
661
  # Check for closing brace
609
662
  if peek_byte == BYTE_RBRACE
610
- @pos += 1 # consume '}'
663
+ @_pos += 1 # consume '}'
611
664
  break
612
665
  end
613
666
 
614
667
  # Parse property name (read until ':')
615
- property_start = @pos
668
+ property_start = @_pos
616
669
  until eof?
617
670
  byte = peek_byte
618
671
  break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
619
672
 
620
- @pos += 1
673
+ @_pos += 1
621
674
  end
622
675
 
623
676
  # Skip if no colon found (malformed)
@@ -628,7 +681,7 @@ module Cataract
628
681
  end
629
682
 
630
683
  # Extract property name - use UTF-8 encoding to support custom properties with Unicode
631
- property = byteslice_encoded(property_start, @pos - property_start)
684
+ property = byteslice_encoded(property_start, @_pos - property_start)
632
685
  property.strip!
633
686
  # Custom properties (--foo) are case-sensitive and can contain Unicode
634
687
  # Regular properties are ASCII-only and case-insensitive
@@ -637,12 +690,12 @@ module Cataract
637
690
  property.force_encoding('US-ASCII')
638
691
  property.downcase!
639
692
  end
640
- @pos += 1 # skip ':'
693
+ @_pos += 1 # skip ':'
641
694
 
642
695
  skip_ws_and_comments
643
696
 
644
697
  # Parse value (read until ';' or '}', but respect quoted strings)
645
- value_start = @pos
698
+ value_start = @_pos
646
699
  important = false
647
700
  in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
648
701
 
@@ -653,9 +706,9 @@ module Cataract
653
706
  # Inside quoted string - only exit on matching quote
654
707
  if byte == in_quote
655
708
  in_quote = nil
656
- elsif byte == BYTE_BACKSLASH && @pos + 1 < @len
709
+ elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
657
710
  # Skip escaped character
658
- @pos += 1
711
+ @_pos += 1
659
712
  end
660
713
  else
661
714
  # Not in quote - check for terminators or quote start
@@ -666,10 +719,10 @@ module Cataract
666
719
  end
667
720
  end
668
721
 
669
- @pos += 1
722
+ @_pos += 1
670
723
  end
671
724
 
672
- value = byteslice_encoded(value_start, @pos - value_start)
725
+ value = byteslice_encoded(value_start, @_pos - value_start)
673
726
  value.strip!
674
727
 
675
728
  # Check for !important (byte-by-byte, no regexp)
@@ -705,7 +758,10 @@ module Cataract
705
758
  end
706
759
 
707
760
  # Skip semicolon if present
708
- @pos += 1 if peek_byte == BYTE_SEMICOLON
761
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
762
+
763
+ # Convert relative URLs to absolute if enabled
764
+ value = convert_urls_in_value(value)
709
765
 
710
766
  # Create Declaration struct
711
767
  declarations << Declaration.new(property, value, important)
@@ -717,35 +773,35 @@ module Cataract
717
773
  # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
718
774
  # Translated from C: see ext/cataract/css_parser.c lines 962-1128
719
775
  def parse_at_rule
720
- at_rule_start = @pos # Points to '@'
721
- @pos += 1 # skip '@'
776
+ at_rule_start = @_pos # Points to '@'
777
+ @_pos += 1 # skip '@'
722
778
 
723
779
  # Find end of at-rule name (stop at whitespace or opening brace)
724
- name_start = @pos
780
+ name_start = @_pos
725
781
  until eof?
726
782
  byte = peek_byte
727
783
  break if whitespace?(byte) || byte == BYTE_LBRACE
728
784
 
729
- @pos += 1
785
+ @_pos += 1
730
786
  end
731
787
 
732
- at_rule_name = byteslice_encoded(name_start, @pos - name_start)
788
+ at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
733
789
 
734
790
  # Handle @charset specially - it's just @charset "value";
735
791
  if at_rule_name == 'charset'
736
792
  skip_ws_and_comments
737
793
  # Read until semicolon
738
- value_start = @pos
794
+ value_start = @_pos
739
795
  while !eof? && peek_byte != BYTE_SEMICOLON
740
- @pos += 1
796
+ @_pos += 1
741
797
  end
742
798
 
743
- charset_value = byteslice_encoded(value_start, @pos - value_start)
799
+ charset_value = byteslice_encoded(value_start, @_pos - value_start)
744
800
  charset_value.strip!
745
801
  # Remove quotes
746
802
  @charset = charset_value.delete('"\'')
747
803
 
748
- @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
804
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
749
805
  return
750
806
  end
751
807
 
@@ -756,9 +812,9 @@ module Cataract
756
812
  warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
757
813
  # Skip to semicolon
758
814
  while !eof? && peek_byte != BYTE_SEMICOLON
759
- @pos += 1
815
+ @_pos += 1
760
816
  end
761
- @pos += 1 if peek_byte == BYTE_SEMICOLON
817
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
762
818
  return
763
819
  end
764
820
 
@@ -773,28 +829,28 @@ module Cataract
773
829
 
774
830
  # Skip to opening brace
775
831
  while !eof? && peek_byte != BYTE_LBRACE
776
- @pos += 1
832
+ @_pos += 1
777
833
  end
778
834
 
779
835
  return if eof? || peek_byte != BYTE_LBRACE
780
836
 
781
- @pos += 1 # skip '{'
837
+ @_pos += 1 # skip '{'
782
838
 
783
839
  # Find matching closing brace
784
- block_start = @pos
785
- block_end = find_matching_brace(@pos)
840
+ block_start = @_pos
841
+ block_end = find_matching_brace(@_pos)
786
842
 
787
843
  # Check depth before recursing
788
- if @depth + 1 > MAX_PARSE_DEPTH
844
+ if @_depth + 1 > MAX_PARSE_DEPTH
789
845
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
790
846
  end
791
847
 
792
848
  # Recursively parse block content (preserve parent media context)
793
849
  nested_parser = Parser.new(
794
850
  byteslice_encoded(block_start, block_end - block_start),
795
- parser_options: @parser_options,
796
- parent_media_sym: @parent_media_sym,
797
- depth: @depth + 1
851
+ parser_options: @_parser_options,
852
+ parent_media_sym: @_parent_media_sym,
853
+ depth: @_depth + 1
798
854
  )
799
855
 
800
856
  nested_result = nested_parser.parse
@@ -804,33 +860,29 @@ module Cataract
804
860
  if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
805
861
  nested_result[:_selector_lists].each do |list_id, rule_ids|
806
862
  new_list_id = list_id + list_id_offset
807
- offsetted_rule_ids = rule_ids.map { |rid| rid + @rule_id_counter }
863
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
808
864
  @_selector_lists[new_list_id] = offsetted_rule_ids
809
865
  end
810
866
  @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
811
867
  end
812
868
 
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
869
+ # NOTE: We no longer build media_index during parse
870
+ # It will be built from MediaQuery objects after import resolution
819
871
 
820
872
  # Add nested rules to main rules array
821
873
  nested_result[:rules].each do |rule|
822
- rule.id = @rule_id_counter
874
+ rule.id = @_rule_id_counter
823
875
  # Update selector_list_id if applicable
824
876
  if rule.is_a?(Rule) && rule.selector_list_id
825
877
  rule.selector_list_id += list_id_offset
826
878
  end
827
- @rule_id_counter += 1
879
+ @_rule_id_counter += 1
828
880
  @rules << rule
829
881
  end
830
882
 
831
883
  # Move position past the closing brace
832
- @pos = block_end
833
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
884
+ @_pos = block_end
885
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
834
886
 
835
887
  return
836
888
  end
@@ -840,16 +892,16 @@ module Cataract
840
892
  skip_ws_and_comments
841
893
 
842
894
  # Find media query (up to opening brace)
843
- mq_start = @pos
895
+ mq_start = @_pos
844
896
  while !eof? && peek_byte != BYTE_LBRACE
845
- @pos += 1
897
+ @_pos += 1
846
898
  end
847
899
 
848
900
  return if eof? || peek_byte != BYTE_LBRACE
849
901
 
850
- mq_end = @pos
902
+ mq_end = @_pos
851
903
  # Trim trailing whitespace
852
- while mq_end > mq_start && whitespace?(@css.getbyte(mq_end - 1))
904
+ while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
853
905
  mq_end -= 1
854
906
  end
855
907
 
@@ -858,34 +910,65 @@ module Cataract
858
910
  child_media_string.strip!
859
911
  child_media_sym = child_media_string.to_sym
860
912
 
913
+ # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
914
+ # Per W3C spec, comma acts as logical OR - each query is independent
915
+ media_query_strings = child_media_string.split(',').map(&:strip)
916
+
917
+ # Create MediaQuery objects for each query in the list
918
+ media_query_ids = []
919
+ media_query_strings.each do |query_string|
920
+ media_type, media_conditions = parse_media_query_parts(query_string)
921
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
922
+ @media_queries << media_query
923
+ media_query_ids << @_media_query_id_counter
924
+ @_media_query_id_counter += 1
925
+ end
926
+
927
+ # If multiple queries, track them as a list for serialization
928
+ if media_query_ids.size > 1
929
+ @_media_query_lists[@_next_media_query_list_id] = media_query_ids
930
+ @_next_media_query_list_id += 1
931
+ end
932
+
933
+ # Use first query ID as the primary one for rules in this block
934
+ current_media_query_id = media_query_ids.first
935
+
861
936
  # Combine with parent media context
862
- combined_media_sym = combine_media_queries(@parent_media_sym, child_media_sym)
937
+ combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
938
+
939
+ # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
940
+ # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
941
+ # MediaQuery combining for nested @media happens in parse_mixed_block instead.
942
+ # So this is just an alias to current_media_query_id.
943
+ combined_media_query_id = current_media_query_id
863
944
 
864
945
  # 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
946
+ unless @media_index.key?(combined_media_sym)
947
+ @_media_query_count += 1
948
+ if @_media_query_count > MAX_MEDIA_QUERIES
868
949
  raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
869
950
  end
870
951
  end
871
952
 
872
- @pos += 1 # skip '{'
953
+ @_pos += 1 # skip '{'
873
954
 
874
955
  # Find matching closing brace
875
- block_start = @pos
876
- block_end = find_matching_brace(@pos)
956
+ block_start = @_pos
957
+ block_end = find_matching_brace(@_pos)
877
958
 
878
959
  # Check depth before recursing
879
- if @depth + 1 > MAX_PARSE_DEPTH
960
+ if @_depth + 1 > MAX_PARSE_DEPTH
880
961
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
881
962
  end
882
963
 
883
964
  # Parse the content with the combined media context
965
+ # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
966
+ # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
884
967
  nested_parser = Parser.new(
885
968
  byteslice_encoded(block_start, block_end - block_start),
886
- parser_options: @parser_options,
969
+ parser_options: @_parser_options,
887
970
  parent_media_sym: combined_media_sym,
888
- depth: @depth + 1
971
+ depth: @_depth + 1
889
972
  )
890
973
 
891
974
  nested_result = nested_parser.parse
@@ -895,50 +978,84 @@ module Cataract
895
978
  if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
896
979
  nested_result[:_selector_lists].each do |list_id, rule_ids|
897
980
  new_list_id = list_id + list_id_offset
898
- offsetted_rule_ids = rule_ids.map { |rid| rid + @rule_id_counter }
981
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
899
982
  @_selector_lists[new_list_id] = offsetted_rule_ids
900
983
  end
901
984
  @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
902
985
  end
903
986
 
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) }
987
+ # Merge nested MediaQuery objects with offsetted IDs
988
+ mq_id_offset = @_media_query_id_counter
989
+ if nested_result[:media_queries] && !nested_result[:media_queries].empty?
990
+ nested_result[:media_queries].each do |mq|
991
+ # Create new MediaQuery with offsetted ID
992
+ new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
993
+ @media_queries << new_mq
994
+ end
995
+ @_media_query_id_counter += nested_result[:media_queries].size
909
996
  end
910
997
 
911
- # Add nested rules to main rules array and update media_index
998
+ # Merge nested media_query_lists with offsetted IDs
999
+ if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1000
+ nested_result[:_media_query_lists].each do |list_id, mq_ids|
1001
+ # Offset the list_id and media_query_ids
1002
+ new_list_id = list_id + @_next_media_query_list_id
1003
+ offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1004
+ @_media_query_lists[new_list_id] = offsetted_mq_ids
1005
+ end
1006
+ @_next_media_query_list_id += nested_result[:_media_query_lists].size
1007
+ end
1008
+
1009
+ # Merge nested media_index into ours (for nested @media)
1010
+ # Note: We no longer build media_index during parse
1011
+ # It will be built from MediaQuery objects after import resolution
1012
+
1013
+ # Add nested rules to main rules array
912
1014
  nested_result[:rules].each do |rule|
913
- rule.id = @rule_id_counter
1015
+ rule.id = @_rule_id_counter
914
1016
  # Update selector_list_id if applicable
915
1017
  if rule.is_a?(Rule) && rule.selector_list_id
916
1018
  rule.selector_list_id += list_id_offset
917
1019
  end
918
1020
 
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
1021
+ # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1022
+ if rule.media_query_id
1023
+ # Nested parser assigned a media_query_id - need to combine with our context
1024
+ nested_mq_id = rule.media_query_id + mq_id_offset
1025
+ nested_mq = @media_queries[nested_mq_id]
1026
+
1027
+ # Combine nested media query with our media context
1028
+ if nested_mq && combined_media_query_id
1029
+ outer_mq = @media_queries[combined_media_query_id]
1030
+ if outer_mq
1031
+ # Combine media queries directly without string building
1032
+ combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
1033
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
1034
+ @media_queries << combined_mq
1035
+ rule.media_query_id = @_media_query_id_counter
1036
+ @_media_query_id_counter += 1
1037
+ else
1038
+ rule.media_query_id = nested_mq_id
1039
+ end
1040
+ else
1041
+ rule.media_query_id = nested_mq_id
928
1042
  end
1043
+ elsif rule.respond_to?(:media_query_id=)
1044
+ # Assign the combined media_query_id if no media_query_id set
1045
+ # (applies to both Rule and AtRule)
1046
+ rule.media_query_id = combined_media_query_id
929
1047
  end
930
1048
 
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
1049
+ # NOTE: We no longer build media_index during parse
1050
+ # It will be built from MediaQuery objects after import resolution
934
1051
 
935
- @rule_id_counter += 1
1052
+ @_rule_id_counter += 1
936
1053
  @rules << rule
937
1054
  end
938
1055
 
939
1056
  # Move position past the closing brace
940
- @pos = block_end
941
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1057
+ @_pos = block_end
1058
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
942
1059
 
943
1060
  return
944
1061
  end
@@ -954,26 +1071,26 @@ module Cataract
954
1071
 
955
1072
  # Skip to opening brace
956
1073
  while !eof? && peek_byte != BYTE_LBRACE
957
- @pos += 1
1074
+ @_pos += 1
958
1075
  end
959
1076
 
960
1077
  return if eof? || peek_byte != BYTE_LBRACE
961
1078
 
962
- selector_end = @pos
1079
+ selector_end = @_pos
963
1080
  # Trim trailing whitespace
964
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1081
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
965
1082
  selector_end -= 1
966
1083
  end
967
1084
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
968
1085
 
969
- @pos += 1 # skip '{'
1086
+ @_pos += 1 # skip '{'
970
1087
 
971
1088
  # Find matching closing brace
972
- block_start = @pos
973
- block_end = find_matching_brace(@pos)
1089
+ block_start = @_pos
1090
+ block_end = find_matching_brace(@_pos)
974
1091
 
975
1092
  # Check depth before recursing
976
- if @depth + 1 > MAX_PARSE_DEPTH
1093
+ if @_depth + 1 > MAX_PARSE_DEPTH
977
1094
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
978
1095
  end
979
1096
 
@@ -981,23 +1098,23 @@ module Cataract
981
1098
  # Create a nested parser context
982
1099
  nested_parser = Parser.new(
983
1100
  byteslice_encoded(block_start, block_end - block_start),
984
- parser_options: @parser_options,
985
- depth: @depth + 1
1101
+ parser_options: @_parser_options,
1102
+ depth: @_depth + 1
986
1103
  )
987
1104
  nested_result = nested_parser.parse
988
1105
  content = nested_result[:rules]
989
1106
 
990
1107
  # Move position past the closing brace
991
- @pos = block_end
1108
+ @_pos = block_end
992
1109
  # The closing brace should be at block_end
993
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1110
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
994
1111
 
995
1112
  # Get rule ID and increment
996
- rule_id = @rule_id_counter
997
- @rule_id_counter += 1
1113
+ rule_id = @_rule_id_counter
1114
+ @_rule_id_counter += 1
998
1115
 
999
1116
  # Create AtRule with nested rules
1000
- at_rule = AtRule.new(rule_id, selector, content, nil)
1117
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1001
1118
  @rules << at_rule
1002
1119
 
1003
1120
  return
@@ -1010,38 +1127,38 @@ module Cataract
1010
1127
 
1011
1128
  # Skip to opening brace
1012
1129
  while !eof? && peek_byte != BYTE_LBRACE
1013
- @pos += 1
1130
+ @_pos += 1
1014
1131
  end
1015
1132
 
1016
1133
  return if eof? || peek_byte != BYTE_LBRACE
1017
1134
 
1018
- selector_end = @pos
1135
+ selector_end = @_pos
1019
1136
  # Trim trailing whitespace
1020
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1137
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1021
1138
  selector_end -= 1
1022
1139
  end
1023
1140
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
1024
1141
 
1025
- @pos += 1 # skip '{'
1142
+ @_pos += 1 # skip '{'
1026
1143
 
1027
1144
  # Find matching closing brace
1028
- decl_start = @pos
1029
- decl_end = find_matching_brace(@pos)
1145
+ decl_start = @_pos
1146
+ decl_end = find_matching_brace(@_pos)
1030
1147
 
1031
1148
  # Parse declarations
1032
1149
  content = parse_declarations_block(decl_start, decl_end)
1033
1150
 
1034
1151
  # Move position past the closing brace
1035
- @pos = decl_end
1152
+ @_pos = decl_end
1036
1153
  # The closing brace should be at decl_end
1037
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1154
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1038
1155
 
1039
1156
  # Get rule ID and increment
1040
- rule_id = @rule_id_counter
1041
- @rule_id_counter += 1
1157
+ rule_id = @_rule_id_counter
1158
+ @_rule_id_counter += 1
1042
1159
 
1043
1160
  # Create AtRule with declarations
1044
- at_rule = AtRule.new(rule_id, selector, content, nil)
1161
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1045
1162
  @rules << at_rule
1046
1163
 
1047
1164
  return
@@ -1053,26 +1170,26 @@ module Cataract
1053
1170
 
1054
1171
  # Skip to opening brace
1055
1172
  until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
1056
- @pos += 1
1173
+ @_pos += 1
1057
1174
  end
1058
1175
 
1059
1176
  return if eof? || peek_byte != BYTE_LBRACE
1060
1177
 
1061
- selector_end = @pos
1178
+ selector_end = @_pos
1062
1179
  # Trim trailing whitespace
1063
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1180
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1064
1181
  selector_end -= 1
1065
1182
  end
1066
1183
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
1067
1184
 
1068
- @pos += 1 # skip '{'
1185
+ @_pos += 1 # skip '{'
1069
1186
 
1070
1187
  # Parse declarations
1071
1188
  declarations = parse_declarations
1072
1189
 
1073
1190
  # Create Rule with declarations
1074
1191
  rule = Rule.new(
1075
- @rule_id_counter, # id
1192
+ @_rule_id_counter, # id
1076
1193
  selector, # selector (e.g., "@property --main-color")
1077
1194
  declarations, # declarations
1078
1195
  nil, # specificity
@@ -1081,7 +1198,7 @@ module Cataract
1081
1198
  )
1082
1199
 
1083
1200
  @rules << rule
1084
- @rule_id_counter += 1
1201
+ @_rule_id_counter += 1
1085
1202
  end
1086
1203
 
1087
1204
  # Check if block contains nested selectors vs just declarations
@@ -1091,16 +1208,16 @@ module Cataract
1091
1208
 
1092
1209
  while pos < end_pos
1093
1210
  # Skip whitespace
1094
- while pos < end_pos && whitespace?(@css.getbyte(pos))
1211
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1095
1212
  pos += 1
1096
1213
  end
1097
1214
  break if pos >= end_pos
1098
1215
 
1099
1216
  # Skip comments
1100
- if pos + 1 < end_pos && @css.getbyte(pos) == BYTE_SLASH && @css.getbyte(pos + 1) == BYTE_STAR
1217
+ if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
1101
1218
  pos += 2
1102
1219
  while pos + 1 < end_pos
1103
- if @css.getbyte(pos) == BYTE_STAR && @css.getbyte(pos + 1) == BYTE_SLASH
1220
+ if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
1104
1221
  pos += 2
1105
1222
  break
1106
1223
  end
@@ -1110,24 +1227,24 @@ module Cataract
1110
1227
  end
1111
1228
 
1112
1229
  # Check for nested selector indicators
1113
- byte = @css.getbyte(pos)
1230
+ byte = @_css.getbyte(pos)
1114
1231
  if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
1115
1232
  byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
1116
1233
  byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
1117
1234
  # Look ahead - if followed by {, it's likely a nested selector
1118
1235
  lookahead = pos + 1
1119
- while lookahead < end_pos && @css.getbyte(lookahead) != BYTE_LBRACE &&
1120
- @css.getbyte(lookahead) != BYTE_SEMICOLON && @css.getbyte(lookahead) != BYTE_NEWLINE
1236
+ while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
1237
+ @_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
1121
1238
  lookahead += 1
1122
1239
  end
1123
- return true if lookahead < end_pos && @css.getbyte(lookahead) == BYTE_LBRACE
1240
+ return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
1124
1241
  end
1125
1242
 
1126
1243
  # Check for @media, @supports, etc nested inside
1127
1244
  return true if byte == BYTE_AT
1128
1245
 
1129
1246
  # Skip to next line or semicolon
1130
- while pos < end_pos && @css.getbyte(pos) != BYTE_SEMICOLON && @css.getbyte(pos) != BYTE_NEWLINE
1247
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
1131
1248
  pos += 1
1132
1249
  end
1133
1250
  pos += 1 if pos < end_pos
@@ -1263,10 +1380,10 @@ module Cataract
1263
1380
  # Skip to next semicolon or closing brace (error recovery)
1264
1381
  def skip_to_semicolon_or_brace
1265
1382
  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
1383
+ @_pos += 1
1267
1384
  end
1268
1385
 
1269
- @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1386
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1270
1387
  end
1271
1388
 
1272
1389
  # Parse an @import statement
@@ -1277,9 +1394,9 @@ module Cataract
1277
1394
 
1278
1395
  # Check for optional url(
1279
1396
  has_url_function = false
1280
- if @pos + 4 <= @len && match_ascii_ci?(@css, @pos, 'url(')
1397
+ if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
1281
1398
  has_url_function = true
1282
- @pos += 4
1399
+ @_pos += 4
1283
1400
  skip_ws_and_comments
1284
1401
  end
1285
1402
 
@@ -1288,24 +1405,24 @@ module Cataract
1288
1405
  if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
1289
1406
  # Invalid @import, skip to semicolon
1290
1407
  while !eof? && peek_byte != BYTE_SEMICOLON
1291
- @pos += 1
1408
+ @_pos += 1
1292
1409
  end
1293
- @pos += 1 unless eof?
1410
+ @_pos += 1 unless eof?
1294
1411
  return
1295
1412
  end
1296
1413
 
1297
1414
  quote_char = byte
1298
- @pos += 1 # Skip opening quote
1415
+ @_pos += 1 # Skip opening quote
1299
1416
 
1300
- url_start = @pos
1417
+ url_start = @_pos
1301
1418
 
1302
1419
  # Find closing quote (handle escaped quotes)
1303
1420
  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
1421
+ @_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
1422
+ 2 # Skip escaped character
1423
+ else
1424
+ 1
1425
+ end
1309
1426
  end
1310
1427
 
1311
1428
  if eof?
@@ -1313,87 +1430,227 @@ module Cataract
1313
1430
  return
1314
1431
  end
1315
1432
 
1316
- url = byteslice_encoded(url_start, @pos - url_start)
1317
- @pos += 1 # Skip closing quote
1433
+ url = byteslice_encoded(url_start, @_pos - url_start)
1434
+ @_pos += 1 # Skip closing quote
1318
1435
 
1319
1436
  # Skip closing paren if we had url(
1320
1437
  if has_url_function
1321
1438
  skip_ws_and_comments
1322
- @pos += 1 if peek_byte == BYTE_RPAREN
1439
+ @_pos += 1 if peek_byte == BYTE_RPAREN
1323
1440
  end
1324
1441
 
1325
1442
  skip_ws_and_comments
1326
1443
 
1327
1444
  # Check for optional media query (everything until semicolon)
1328
- media = nil
1445
+ media_string = nil
1446
+ media_query_id = nil
1329
1447
  if !eof? && peek_byte != BYTE_SEMICOLON
1330
- media_start = @pos
1448
+ media_start = @_pos
1331
1449
 
1332
1450
  # Find semicolon
1333
1451
  while !eof? && peek_byte != BYTE_SEMICOLON
1334
- @pos += 1
1452
+ @_pos += 1
1335
1453
  end
1336
1454
 
1337
- media_end = @pos
1455
+ media_end = @_pos
1338
1456
 
1339
1457
  # Trim trailing whitespace from media query
1340
- while media_end > media_start && whitespace?(@css.getbyte(media_end - 1))
1458
+ while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
1341
1459
  media_end -= 1
1342
1460
  end
1343
1461
 
1344
1462
  if media_end > media_start
1345
- media = byteslice_encoded(media_start, media_end - media_start).to_sym
1463
+ media_string = byteslice_encoded(media_start, media_end - media_start)
1464
+
1465
+ # Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
1466
+ media_query_strings = media_string.split(',').map(&:strip)
1467
+
1468
+ # Create MediaQuery objects for each query in the list
1469
+ media_query_ids = []
1470
+ media_query_strings.each do |query_string|
1471
+ media_type, media_conditions = parse_media_query_parts(query_string)
1472
+
1473
+ # If we have a parent import's media context, combine them
1474
+ parent_import_type = @_parser_options[:parent_import_media_type]
1475
+ parent_import_conditions = @_parser_options[:parent_import_media_conditions]
1476
+
1477
+ if parent_import_type
1478
+ # Combine: parent's type is the effective type
1479
+ # Conditions are combined with "and"
1480
+ combined_type = parent_import_type
1481
+ combined_conditions = if parent_import_conditions && media_conditions
1482
+ "#{parent_import_conditions} and #{media_conditions}"
1483
+ elsif parent_import_conditions
1484
+ "#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
1485
+ elsif media_conditions
1486
+ media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
1487
+ else
1488
+ media_type == parent_import_type ? nil : media_type.to_s
1489
+ end
1490
+
1491
+ media_type = combined_type
1492
+ media_conditions = combined_conditions
1493
+ end
1494
+
1495
+ # Create MediaQuery object
1496
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1497
+ @media_queries << media_query
1498
+ media_query_ids << @_media_query_id_counter
1499
+ @_media_query_id_counter += 1
1500
+ end
1501
+
1502
+ # Use the first media query ID for the import statement
1503
+ # (The list is tracked separately for serialization)
1504
+ media_query_id = media_query_ids.first
1505
+
1506
+ # If multiple queries, track them as a list for serialization
1507
+ if media_query_ids.size > 1
1508
+ media_query_list_id = @_next_media_query_list_id
1509
+ @_media_query_lists[media_query_list_id] = media_query_ids
1510
+ @_next_media_query_list_id += 1
1511
+ end
1346
1512
  end
1347
1513
  end
1348
1514
 
1349
1515
  # Skip semicolon
1350
- @pos += 1 if peek_byte == BYTE_SEMICOLON
1516
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
1351
1517
 
1352
1518
  # Create ImportStatement (resolved: false by default)
1353
- import_stmt = ImportStatement.new(@rule_id_counter, url, media, false)
1519
+ import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
1354
1520
  @imports << import_stmt
1355
- @rule_id_counter += 1
1521
+ @_rule_id_counter += 1
1356
1522
  end
1357
1523
 
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?
1524
+ # Convert relative URLs in a value string to absolute URLs
1525
+ # Called when @_absolute_paths is enabled and @_base_uri is set
1526
+ #
1527
+ # @param value [String] The declaration value to process
1528
+ # @return [String] Value with relative URLs converted to absolute
1529
+ def convert_urls_in_value(value)
1530
+ return value unless @_absolute_paths && @_base_uri
1531
+
1532
+ result = +''
1533
+ pos = 0
1534
+ len = value.bytesize
1535
+
1536
+ while pos < len
1537
+ # Look for 'url(' - case insensitive
1538
+ byte = value.getbyte(pos)
1539
+ if pos + 3 < len &&
1540
+ (byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
1541
+ (value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
1542
+ (value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
1543
+ value.getbyte(pos + 3) == BYTE_LPAREN
1544
+
1545
+ result << value.byteslice(pos, 4) # append 'url('
1546
+ pos += 4
1547
+
1548
+ # Skip whitespace
1549
+ while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1550
+ result << value.getbyte(pos).chr
1551
+ pos += 1
1552
+ end
1367
1553
 
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
1554
+ # Check for quote
1555
+ quote_char = nil
1556
+ if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
1557
+ quote_char = value.getbyte(pos)
1558
+ pos += 1
1559
+ end
1560
+
1561
+ # Extract URL
1562
+ url_start = pos
1563
+ if quote_char
1564
+ # Scan until matching quote
1565
+ while pos < len && value.getbyte(pos) != quote_char
1566
+ # Handle escape
1567
+ pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
1568
+ 2
1569
+ else
1570
+ 1
1571
+ end
1572
+ end
1573
+ else
1574
+ # Scan until ) or whitespace
1575
+ while pos < len
1576
+ b = value.getbyte(pos)
1577
+ break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
1578
+
1579
+ pos += 1
1375
1580
  end
1376
- @pos += 1
1377
1581
  end
1378
- next
1379
- end
1380
1582
 
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
1583
+ url_str = value.byteslice(url_start, pos - url_start)
1584
+
1585
+ # Check if URL needs resolution (is relative)
1586
+ # Skip if: contains "://" OR starts with "data:"
1587
+ needs_resolution = true
1588
+ if url_str.empty?
1589
+ needs_resolution = false
1590
+ else
1591
+ # Check for "://"
1592
+ i = 0
1593
+ url_len = url_str.bytesize
1594
+ while i + 2 < url_len
1595
+ if url_str.getbyte(i) == BYTE_COLON &&
1596
+ url_str.getbyte(i + 1) == BYTE_SLASH &&
1597
+ url_str.getbyte(i + 2) == BYTE_SLASH
1598
+ needs_resolution = false
1599
+ break
1600
+ end
1601
+ i += 1
1602
+ end
1603
+
1604
+ # Check for "data:" prefix (case insensitive)
1605
+ if needs_resolution && url_len >= 5
1606
+ if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
1607
+ (url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
1608
+ (url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
1609
+ (url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
1610
+ url_str.getbyte(4) == BYTE_COLON
1611
+ needs_resolution = false
1612
+ end
1388
1613
  end
1389
- @pos += 1 unless eof? # Skip semicolon
1390
- next
1391
1614
  end
1392
- end
1393
1615
 
1394
- # Hit non-@import content, stop skipping
1395
- break
1616
+ if needs_resolution
1617
+ # Resolve relative URL using the resolver proc
1618
+ begin
1619
+ resolved = @_uri_resolver.call(@_base_uri, url_str)
1620
+ result << "'" << resolved << "'"
1621
+ rescue StandardError
1622
+ # If resolution fails, preserve original
1623
+ if quote_char
1624
+ result << quote_char.chr << url_str << quote_char.chr
1625
+ else
1626
+ result << url_str
1627
+ end
1628
+ end
1629
+ elsif url_str.empty?
1630
+ # Preserve original URL
1631
+ result << "''"
1632
+ elsif quote_char
1633
+ result << quote_char.chr << url_str << quote_char.chr
1634
+ else
1635
+ result << url_str
1636
+ end
1637
+
1638
+ # Skip past closing quote if present
1639
+ pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
1640
+
1641
+ # Skip whitespace before )
1642
+ while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
1643
+ pos += 1
1644
+ end
1645
+
1646
+ # The ) will be copied in the next iteration or at the end
1647
+ else
1648
+ result << byte.chr
1649
+ pos += 1
1650
+ end
1396
1651
  end
1652
+
1653
+ result
1397
1654
  end
1398
1655
 
1399
1656
  # Parse a block of declarations given start/end positions
@@ -1405,7 +1662,7 @@ module Cataract
1405
1662
 
1406
1663
  while pos < end_pos
1407
1664
  # Skip whitespace
1408
- while pos < end_pos && whitespace?(@css.getbyte(pos))
1665
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1409
1666
  pos += 1
1410
1667
  end
1411
1668
  break if pos >= end_pos
@@ -1417,5 +1674,115 @@ module Cataract
1417
1674
 
1418
1675
  declarations
1419
1676
  end
1677
+
1678
+ # Combine parent and child media query parts directly without string building
1679
+ #
1680
+ # The parent's type takes precedence (child type is ignored per CSS spec).
1681
+ #
1682
+ # @param parent_mq [MediaQuery] Parent media query object
1683
+ # @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
1684
+ # @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
1685
+ #
1686
+ # @example
1687
+ # combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
1688
+ def combine_media_query_parts(parent_mq, child_conditions)
1689
+ # Type: parent's type wins (outermost type)
1690
+ combined_type = parent_mq.type
1691
+
1692
+ # Conditions: combine parent and child conditions
1693
+ combined_conditions = if parent_mq.conditions && child_conditions
1694
+ "#{parent_mq.conditions} and #{child_conditions}"
1695
+ elsif parent_mq.conditions
1696
+ parent_mq.conditions
1697
+ elsif child_conditions
1698
+ child_conditions
1699
+ end
1700
+
1701
+ [combined_type, combined_conditions]
1702
+ end
1703
+
1704
+ # Parse media query string into type and conditions
1705
+ #
1706
+ # @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
1707
+ # @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
1708
+ #
1709
+ # @example
1710
+ # parse_media_query_parts("screen") #=> [:screen, nil]
1711
+ # parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
1712
+ # parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
1713
+ def parse_media_query_parts(query)
1714
+ i = 0
1715
+ len = query.bytesize
1716
+
1717
+ # Skip leading whitespace
1718
+ while i < len && whitespace?(query.getbyte(i))
1719
+ i += 1
1720
+ end
1721
+
1722
+ return [:all, nil] if i >= len
1723
+
1724
+ # Check if starts with '(' - media feature without type (defaults to :all)
1725
+ if query.getbyte(i) == BYTE_LPAREN
1726
+ return [:all, query.byteslice(i, len - i)]
1727
+ end
1728
+
1729
+ # Find first media type word
1730
+ word_start = i
1731
+ while i < len
1732
+ byte = query.getbyte(i)
1733
+ break if whitespace?(byte) || byte == BYTE_LPAREN
1734
+
1735
+ i += 1
1736
+ end
1737
+
1738
+ type = query.byteslice(word_start, i - word_start).to_sym
1739
+
1740
+ # Skip whitespace after type
1741
+ while i < len && whitespace?(query.getbyte(i))
1742
+ i += 1
1743
+ end
1744
+
1745
+ # Check if there's more (conditions)
1746
+ if i >= len
1747
+ return [type, nil]
1748
+ end
1749
+
1750
+ # Look for " and " keyword (case-insensitive)
1751
+ # We need to find "and" as a separate word
1752
+ and_pos = nil
1753
+ check_i = i
1754
+ while check_i < len - 2
1755
+ # Check for 'and' (a=97/65, n=110/78, d=100/68)
1756
+ byte0 = query.getbyte(check_i)
1757
+ byte1 = query.getbyte(check_i + 1)
1758
+ byte2 = query.getbyte(check_i + 2)
1759
+
1760
+ if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
1761
+ (byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
1762
+ (byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
1763
+ # Make sure it's a word boundary (whitespace before and after)
1764
+ before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
1765
+ after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
1766
+ if before_ok && after_ok
1767
+ and_pos = check_i
1768
+ break
1769
+ end
1770
+ end
1771
+ check_i += 1
1772
+ end
1773
+
1774
+ if and_pos
1775
+ # Skip past "and " to get conditions
1776
+ conditions_start = and_pos + 3 # skip "and"
1777
+ while conditions_start < len && whitespace?(query.getbyte(conditions_start))
1778
+ conditions_start += 1
1779
+ end
1780
+ conditions = query.byteslice(conditions_start, len - conditions_start)
1781
+ [type, conditions]
1782
+ else
1783
+ # No "and" found - rest is conditions (unusual but possible)
1784
+ [type, query.byteslice(i, len - i)]
1785
+ end
1786
+ end
1420
1787
  end
1421
1788
  end