cataract 0.2.2 → 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,22 +690,39 @@ 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
- # Parse value (read until ';' or '}')
645
- value_start = @pos
697
+ # Parse value (read until ';' or '}', but respect quoted strings)
698
+ value_start = @_pos
646
699
  important = false
700
+ in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
647
701
 
648
702
  until eof?
649
703
  byte = peek_byte
650
- break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
651
704
 
652
- @pos += 1
705
+ if in_quote
706
+ # Inside quoted string - only exit on matching quote
707
+ if byte == in_quote
708
+ in_quote = nil
709
+ elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
710
+ # Skip escaped character
711
+ @_pos += 1
712
+ end
713
+ else
714
+ # Not in quote - check for terminators or quote start
715
+ break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
716
+
717
+ if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE
718
+ in_quote = byte
719
+ end
720
+ end
721
+
722
+ @_pos += 1
653
723
  end
654
724
 
655
- value = byteslice_encoded(value_start, @pos - value_start)
725
+ value = byteslice_encoded(value_start, @_pos - value_start)
656
726
  value.strip!
657
727
 
658
728
  # Check for !important (byte-by-byte, no regexp)
@@ -688,7 +758,10 @@ module Cataract
688
758
  end
689
759
 
690
760
  # Skip semicolon if present
691
- @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)
692
765
 
693
766
  # Create Declaration struct
694
767
  declarations << Declaration.new(property, value, important)
@@ -700,35 +773,35 @@ module Cataract
700
773
  # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
701
774
  # Translated from C: see ext/cataract/css_parser.c lines 962-1128
702
775
  def parse_at_rule
703
- at_rule_start = @pos # Points to '@'
704
- @pos += 1 # skip '@'
776
+ at_rule_start = @_pos # Points to '@'
777
+ @_pos += 1 # skip '@'
705
778
 
706
779
  # Find end of at-rule name (stop at whitespace or opening brace)
707
- name_start = @pos
780
+ name_start = @_pos
708
781
  until eof?
709
782
  byte = peek_byte
710
783
  break if whitespace?(byte) || byte == BYTE_LBRACE
711
784
 
712
- @pos += 1
785
+ @_pos += 1
713
786
  end
714
787
 
715
- at_rule_name = byteslice_encoded(name_start, @pos - name_start)
788
+ at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
716
789
 
717
790
  # Handle @charset specially - it's just @charset "value";
718
791
  if at_rule_name == 'charset'
719
792
  skip_ws_and_comments
720
793
  # Read until semicolon
721
- value_start = @pos
794
+ value_start = @_pos
722
795
  while !eof? && peek_byte != BYTE_SEMICOLON
723
- @pos += 1
796
+ @_pos += 1
724
797
  end
725
798
 
726
- charset_value = byteslice_encoded(value_start, @pos - value_start)
799
+ charset_value = byteslice_encoded(value_start, @_pos - value_start)
727
800
  charset_value.strip!
728
801
  # Remove quotes
729
802
  @charset = charset_value.delete('"\'')
730
803
 
731
- @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
804
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
732
805
  return
733
806
  end
734
807
 
@@ -739,9 +812,9 @@ module Cataract
739
812
  warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
740
813
  # Skip to semicolon
741
814
  while !eof? && peek_byte != BYTE_SEMICOLON
742
- @pos += 1
815
+ @_pos += 1
743
816
  end
744
- @pos += 1 if peek_byte == BYTE_SEMICOLON
817
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
745
818
  return
746
819
  end
747
820
 
@@ -756,28 +829,28 @@ module Cataract
756
829
 
757
830
  # Skip to opening brace
758
831
  while !eof? && peek_byte != BYTE_LBRACE
759
- @pos += 1
832
+ @_pos += 1
760
833
  end
761
834
 
762
835
  return if eof? || peek_byte != BYTE_LBRACE
763
836
 
764
- @pos += 1 # skip '{'
837
+ @_pos += 1 # skip '{'
765
838
 
766
839
  # Find matching closing brace
767
- block_start = @pos
768
- block_end = find_matching_brace(@pos)
840
+ block_start = @_pos
841
+ block_end = find_matching_brace(@_pos)
769
842
 
770
843
  # Check depth before recursing
771
- if @depth + 1 > MAX_PARSE_DEPTH
844
+ if @_depth + 1 > MAX_PARSE_DEPTH
772
845
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
773
846
  end
774
847
 
775
848
  # Recursively parse block content (preserve parent media context)
776
849
  nested_parser = Parser.new(
777
850
  byteslice_encoded(block_start, block_end - block_start),
778
- parser_options: @parser_options,
779
- parent_media_sym: @parent_media_sym,
780
- depth: @depth + 1
851
+ parser_options: @_parser_options,
852
+ parent_media_sym: @_parent_media_sym,
853
+ depth: @_depth + 1
781
854
  )
782
855
 
783
856
  nested_result = nested_parser.parse
@@ -787,33 +860,29 @@ module Cataract
787
860
  if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
788
861
  nested_result[:_selector_lists].each do |list_id, rule_ids|
789
862
  new_list_id = list_id + list_id_offset
790
- offsetted_rule_ids = rule_ids.map { |rid| rid + @rule_id_counter }
863
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
791
864
  @_selector_lists[new_list_id] = offsetted_rule_ids
792
865
  end
793
866
  @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
794
867
  end
795
868
 
796
- # Merge nested media_index into ours
797
- nested_result[:_media_index].each do |media, rule_ids|
798
- @_media_index[media] ||= []
799
- # Use each + << instead of concat + map (1.20x faster for small arrays)
800
- rule_ids.each { |rid| @_media_index[media] << (@rule_id_counter + rid) }
801
- end
869
+ # NOTE: We no longer build media_index during parse
870
+ # It will be built from MediaQuery objects after import resolution
802
871
 
803
872
  # Add nested rules to main rules array
804
873
  nested_result[:rules].each do |rule|
805
- rule.id = @rule_id_counter
874
+ rule.id = @_rule_id_counter
806
875
  # Update selector_list_id if applicable
807
876
  if rule.is_a?(Rule) && rule.selector_list_id
808
877
  rule.selector_list_id += list_id_offset
809
878
  end
810
- @rule_id_counter += 1
879
+ @_rule_id_counter += 1
811
880
  @rules << rule
812
881
  end
813
882
 
814
883
  # Move position past the closing brace
815
- @pos = block_end
816
- @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
817
886
 
818
887
  return
819
888
  end
@@ -823,16 +892,16 @@ module Cataract
823
892
  skip_ws_and_comments
824
893
 
825
894
  # Find media query (up to opening brace)
826
- mq_start = @pos
895
+ mq_start = @_pos
827
896
  while !eof? && peek_byte != BYTE_LBRACE
828
- @pos += 1
897
+ @_pos += 1
829
898
  end
830
899
 
831
900
  return if eof? || peek_byte != BYTE_LBRACE
832
901
 
833
- mq_end = @pos
902
+ mq_end = @_pos
834
903
  # Trim trailing whitespace
835
- while mq_end > mq_start && whitespace?(@css.getbyte(mq_end - 1))
904
+ while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
836
905
  mq_end -= 1
837
906
  end
838
907
 
@@ -841,34 +910,65 @@ module Cataract
841
910
  child_media_string.strip!
842
911
  child_media_sym = child_media_string.to_sym
843
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
+
844
936
  # Combine with parent media context
845
- 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
846
944
 
847
945
  # Check media query limit
848
- unless @_media_index.key?(combined_media_sym)
849
- @media_query_count += 1
850
- 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
851
949
  raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
852
950
  end
853
951
  end
854
952
 
855
- @pos += 1 # skip '{'
953
+ @_pos += 1 # skip '{'
856
954
 
857
955
  # Find matching closing brace
858
- block_start = @pos
859
- block_end = find_matching_brace(@pos)
956
+ block_start = @_pos
957
+ block_end = find_matching_brace(@_pos)
860
958
 
861
959
  # Check depth before recursing
862
- if @depth + 1 > MAX_PARSE_DEPTH
960
+ if @_depth + 1 > MAX_PARSE_DEPTH
863
961
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
864
962
  end
865
963
 
866
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
867
967
  nested_parser = Parser.new(
868
968
  byteslice_encoded(block_start, block_end - block_start),
869
- parser_options: @parser_options,
969
+ parser_options: @_parser_options,
870
970
  parent_media_sym: combined_media_sym,
871
- depth: @depth + 1
971
+ depth: @_depth + 1
872
972
  )
873
973
 
874
974
  nested_result = nested_parser.parse
@@ -878,50 +978,84 @@ module Cataract
878
978
  if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
879
979
  nested_result[:_selector_lists].each do |list_id, rule_ids|
880
980
  new_list_id = list_id + list_id_offset
881
- offsetted_rule_ids = rule_ids.map { |rid| rid + @rule_id_counter }
981
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
882
982
  @_selector_lists[new_list_id] = offsetted_rule_ids
883
983
  end
884
984
  @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
885
985
  end
886
986
 
887
- # Merge nested media_index into ours (for nested @media)
888
- nested_result[:_media_index].each do |media, rule_ids|
889
- @_media_index[media] ||= []
890
- # Use each + << instead of concat + map (1.20x faster for small arrays)
891
- 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
996
+ end
997
+
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
892
1007
  end
893
1008
 
894
- # Add nested rules to main rules array and update media_index
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
895
1014
  nested_result[:rules].each do |rule|
896
- rule.id = @rule_id_counter
1015
+ rule.id = @_rule_id_counter
897
1016
  # Update selector_list_id if applicable
898
1017
  if rule.is_a?(Rule) && rule.selector_list_id
899
1018
  rule.selector_list_id += list_id_offset
900
1019
  end
901
1020
 
902
- # Extract media types and add to each first (if different from full query)
903
- # We add these BEFORE the full query so that when iterating the media_index hash,
904
- # the full query comes last and takes precedence during serialization
905
- media_types = Cataract.parse_media_types(combined_media_sym)
906
- media_types.each do |media_type|
907
- # Only add if different from combined_media_sym to avoid duplication
908
- if media_type != combined_media_sym
909
- @_media_index[media_type] ||= []
910
- @_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
911
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
912
1047
  end
913
1048
 
914
- # Add to full query symbol (after media types for insertion order)
915
- @_media_index[combined_media_sym] ||= []
916
- @_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
917
1051
 
918
- @rule_id_counter += 1
1052
+ @_rule_id_counter += 1
919
1053
  @rules << rule
920
1054
  end
921
1055
 
922
1056
  # Move position past the closing brace
923
- @pos = block_end
924
- @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
925
1059
 
926
1060
  return
927
1061
  end
@@ -937,26 +1071,26 @@ module Cataract
937
1071
 
938
1072
  # Skip to opening brace
939
1073
  while !eof? && peek_byte != BYTE_LBRACE
940
- @pos += 1
1074
+ @_pos += 1
941
1075
  end
942
1076
 
943
1077
  return if eof? || peek_byte != BYTE_LBRACE
944
1078
 
945
- selector_end = @pos
1079
+ selector_end = @_pos
946
1080
  # Trim trailing whitespace
947
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1081
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
948
1082
  selector_end -= 1
949
1083
  end
950
1084
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
951
1085
 
952
- @pos += 1 # skip '{'
1086
+ @_pos += 1 # skip '{'
953
1087
 
954
1088
  # Find matching closing brace
955
- block_start = @pos
956
- block_end = find_matching_brace(@pos)
1089
+ block_start = @_pos
1090
+ block_end = find_matching_brace(@_pos)
957
1091
 
958
1092
  # Check depth before recursing
959
- if @depth + 1 > MAX_PARSE_DEPTH
1093
+ if @_depth + 1 > MAX_PARSE_DEPTH
960
1094
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
961
1095
  end
962
1096
 
@@ -964,23 +1098,23 @@ module Cataract
964
1098
  # Create a nested parser context
965
1099
  nested_parser = Parser.new(
966
1100
  byteslice_encoded(block_start, block_end - block_start),
967
- parser_options: @parser_options,
968
- depth: @depth + 1
1101
+ parser_options: @_parser_options,
1102
+ depth: @_depth + 1
969
1103
  )
970
1104
  nested_result = nested_parser.parse
971
1105
  content = nested_result[:rules]
972
1106
 
973
1107
  # Move position past the closing brace
974
- @pos = block_end
1108
+ @_pos = block_end
975
1109
  # The closing brace should be at block_end
976
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1110
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
977
1111
 
978
1112
  # Get rule ID and increment
979
- rule_id = @rule_id_counter
980
- @rule_id_counter += 1
1113
+ rule_id = @_rule_id_counter
1114
+ @_rule_id_counter += 1
981
1115
 
982
1116
  # Create AtRule with nested rules
983
- at_rule = AtRule.new(rule_id, selector, content, nil)
1117
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
984
1118
  @rules << at_rule
985
1119
 
986
1120
  return
@@ -993,38 +1127,38 @@ module Cataract
993
1127
 
994
1128
  # Skip to opening brace
995
1129
  while !eof? && peek_byte != BYTE_LBRACE
996
- @pos += 1
1130
+ @_pos += 1
997
1131
  end
998
1132
 
999
1133
  return if eof? || peek_byte != BYTE_LBRACE
1000
1134
 
1001
- selector_end = @pos
1135
+ selector_end = @_pos
1002
1136
  # Trim trailing whitespace
1003
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1137
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1004
1138
  selector_end -= 1
1005
1139
  end
1006
1140
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
1007
1141
 
1008
- @pos += 1 # skip '{'
1142
+ @_pos += 1 # skip '{'
1009
1143
 
1010
1144
  # Find matching closing brace
1011
- decl_start = @pos
1012
- decl_end = find_matching_brace(@pos)
1145
+ decl_start = @_pos
1146
+ decl_end = find_matching_brace(@_pos)
1013
1147
 
1014
1148
  # Parse declarations
1015
1149
  content = parse_declarations_block(decl_start, decl_end)
1016
1150
 
1017
1151
  # Move position past the closing brace
1018
- @pos = decl_end
1152
+ @_pos = decl_end
1019
1153
  # The closing brace should be at decl_end
1020
- @pos += 1 if @pos < @len && @css.getbyte(@pos) == BYTE_RBRACE
1154
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1021
1155
 
1022
1156
  # Get rule ID and increment
1023
- rule_id = @rule_id_counter
1024
- @rule_id_counter += 1
1157
+ rule_id = @_rule_id_counter
1158
+ @_rule_id_counter += 1
1025
1159
 
1026
1160
  # Create AtRule with declarations
1027
- at_rule = AtRule.new(rule_id, selector, content, nil)
1161
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1028
1162
  @rules << at_rule
1029
1163
 
1030
1164
  return
@@ -1036,26 +1170,26 @@ module Cataract
1036
1170
 
1037
1171
  # Skip to opening brace
1038
1172
  until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
1039
- @pos += 1
1173
+ @_pos += 1
1040
1174
  end
1041
1175
 
1042
1176
  return if eof? || peek_byte != BYTE_LBRACE
1043
1177
 
1044
- selector_end = @pos
1178
+ selector_end = @_pos
1045
1179
  # Trim trailing whitespace
1046
- while selector_end > selector_start && whitespace?(@css.getbyte(selector_end - 1))
1180
+ while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1047
1181
  selector_end -= 1
1048
1182
  end
1049
1183
  selector = byteslice_encoded(selector_start, selector_end - selector_start)
1050
1184
 
1051
- @pos += 1 # skip '{'
1185
+ @_pos += 1 # skip '{'
1052
1186
 
1053
1187
  # Parse declarations
1054
1188
  declarations = parse_declarations
1055
1189
 
1056
1190
  # Create Rule with declarations
1057
1191
  rule = Rule.new(
1058
- @rule_id_counter, # id
1192
+ @_rule_id_counter, # id
1059
1193
  selector, # selector (e.g., "@property --main-color")
1060
1194
  declarations, # declarations
1061
1195
  nil, # specificity
@@ -1064,7 +1198,7 @@ module Cataract
1064
1198
  )
1065
1199
 
1066
1200
  @rules << rule
1067
- @rule_id_counter += 1
1201
+ @_rule_id_counter += 1
1068
1202
  end
1069
1203
 
1070
1204
  # Check if block contains nested selectors vs just declarations
@@ -1074,16 +1208,16 @@ module Cataract
1074
1208
 
1075
1209
  while pos < end_pos
1076
1210
  # Skip whitespace
1077
- while pos < end_pos && whitespace?(@css.getbyte(pos))
1211
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1078
1212
  pos += 1
1079
1213
  end
1080
1214
  break if pos >= end_pos
1081
1215
 
1082
1216
  # Skip comments
1083
- 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
1084
1218
  pos += 2
1085
1219
  while pos + 1 < end_pos
1086
- 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
1087
1221
  pos += 2
1088
1222
  break
1089
1223
  end
@@ -1093,24 +1227,24 @@ module Cataract
1093
1227
  end
1094
1228
 
1095
1229
  # Check for nested selector indicators
1096
- byte = @css.getbyte(pos)
1230
+ byte = @_css.getbyte(pos)
1097
1231
  if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
1098
1232
  byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
1099
1233
  byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
1100
1234
  # Look ahead - if followed by {, it's likely a nested selector
1101
1235
  lookahead = pos + 1
1102
- while lookahead < end_pos && @css.getbyte(lookahead) != BYTE_LBRACE &&
1103
- @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
1104
1238
  lookahead += 1
1105
1239
  end
1106
- return true if lookahead < end_pos && @css.getbyte(lookahead) == BYTE_LBRACE
1240
+ return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
1107
1241
  end
1108
1242
 
1109
1243
  # Check for @media, @supports, etc nested inside
1110
1244
  return true if byte == BYTE_AT
1111
1245
 
1112
1246
  # Skip to next line or semicolon
1113
- 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
1114
1248
  pos += 1
1115
1249
  end
1116
1250
  pos += 1 if pos < end_pos
@@ -1246,10 +1380,10 @@ module Cataract
1246
1380
  # Skip to next semicolon or closing brace (error recovery)
1247
1381
  def skip_to_semicolon_or_brace
1248
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
1249
- @pos += 1
1383
+ @_pos += 1
1250
1384
  end
1251
1385
 
1252
- @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1386
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1253
1387
  end
1254
1388
 
1255
1389
  # Parse an @import statement
@@ -1260,9 +1394,9 @@ module Cataract
1260
1394
 
1261
1395
  # Check for optional url(
1262
1396
  has_url_function = false
1263
- if @pos + 4 <= @len && match_ascii_ci?(@css, @pos, 'url(')
1397
+ if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
1264
1398
  has_url_function = true
1265
- @pos += 4
1399
+ @_pos += 4
1266
1400
  skip_ws_and_comments
1267
1401
  end
1268
1402
 
@@ -1271,24 +1405,24 @@ module Cataract
1271
1405
  if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
1272
1406
  # Invalid @import, skip to semicolon
1273
1407
  while !eof? && peek_byte != BYTE_SEMICOLON
1274
- @pos += 1
1408
+ @_pos += 1
1275
1409
  end
1276
- @pos += 1 unless eof?
1410
+ @_pos += 1 unless eof?
1277
1411
  return
1278
1412
  end
1279
1413
 
1280
1414
  quote_char = byte
1281
- @pos += 1 # Skip opening quote
1415
+ @_pos += 1 # Skip opening quote
1282
1416
 
1283
- url_start = @pos
1417
+ url_start = @_pos
1284
1418
 
1285
1419
  # Find closing quote (handle escaped quotes)
1286
1420
  while !eof? && peek_byte != quote_char
1287
- @pos += if peek_byte == BYTE_BACKSLASH && @pos + 1 < @len
1288
- 2 # Skip escaped character
1289
- else
1290
- 1
1291
- end
1421
+ @_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
1422
+ 2 # Skip escaped character
1423
+ else
1424
+ 1
1425
+ end
1292
1426
  end
1293
1427
 
1294
1428
  if eof?
@@ -1296,87 +1430,227 @@ module Cataract
1296
1430
  return
1297
1431
  end
1298
1432
 
1299
- url = byteslice_encoded(url_start, @pos - url_start)
1300
- @pos += 1 # Skip closing quote
1433
+ url = byteslice_encoded(url_start, @_pos - url_start)
1434
+ @_pos += 1 # Skip closing quote
1301
1435
 
1302
1436
  # Skip closing paren if we had url(
1303
1437
  if has_url_function
1304
1438
  skip_ws_and_comments
1305
- @pos += 1 if peek_byte == BYTE_RPAREN
1439
+ @_pos += 1 if peek_byte == BYTE_RPAREN
1306
1440
  end
1307
1441
 
1308
1442
  skip_ws_and_comments
1309
1443
 
1310
1444
  # Check for optional media query (everything until semicolon)
1311
- media = nil
1445
+ media_string = nil
1446
+ media_query_id = nil
1312
1447
  if !eof? && peek_byte != BYTE_SEMICOLON
1313
- media_start = @pos
1448
+ media_start = @_pos
1314
1449
 
1315
1450
  # Find semicolon
1316
1451
  while !eof? && peek_byte != BYTE_SEMICOLON
1317
- @pos += 1
1452
+ @_pos += 1
1318
1453
  end
1319
1454
 
1320
- media_end = @pos
1455
+ media_end = @_pos
1321
1456
 
1322
1457
  # Trim trailing whitespace from media query
1323
- while media_end > media_start && whitespace?(@css.getbyte(media_end - 1))
1458
+ while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
1324
1459
  media_end -= 1
1325
1460
  end
1326
1461
 
1327
1462
  if media_end > media_start
1328
- 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
1329
1512
  end
1330
1513
  end
1331
1514
 
1332
1515
  # Skip semicolon
1333
- @pos += 1 if peek_byte == BYTE_SEMICOLON
1516
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
1334
1517
 
1335
1518
  # Create ImportStatement (resolved: false by default)
1336
- 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)
1337
1520
  @imports << import_stmt
1338
- @rule_id_counter += 1
1521
+ @_rule_id_counter += 1
1339
1522
  end
1340
1523
 
1341
- # Skip @import statements at the beginning of CSS (DEPRECATED - now parsed)
1342
- # Per CSS spec, @import must come before all rules (except @charset)
1343
- def skip_imports
1344
- until eof?
1345
- # Skip whitespace
1346
- while !eof? && whitespace?(peek_byte)
1347
- @pos += 1
1348
- end
1349
- 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
1350
1553
 
1351
- # Skip comments
1352
- if @pos + 1 < @len && @css.getbyte(@pos) == BYTE_SLASH && @css.getbyte(@pos + 1) == BYTE_STAR
1353
- @pos += 2
1354
- while @pos + 1 < @len
1355
- if @css.getbyte(@pos) == BYTE_STAR && @css.getbyte(@pos + 1) == BYTE_SLASH
1356
- @pos += 2
1357
- 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
1358
1580
  end
1359
- @pos += 1
1360
1581
  end
1361
- next
1362
- end
1363
1582
 
1364
- # Check for @import (case-insensitive byte comparison)
1365
- if @pos + 7 <= @len && @css.getbyte(@pos) == BYTE_AT && match_ascii_ci?(@css, @pos + 1, 'import')
1366
- # Check that it's followed by whitespace or quote
1367
- if @pos + 7 >= @len || whitespace?(@css.getbyte(@pos + 7)) || @css.getbyte(@pos + 7) == BYTE_SQUOTE || @css.getbyte(@pos + 7) == BYTE_DQUOTE
1368
- # Skip to semicolon
1369
- while !eof? && peek_byte != BYTE_SEMICOLON
1370
- @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
1371
1613
  end
1372
- @pos += 1 unless eof? # Skip semicolon
1373
- next
1374
1614
  end
1375
- end
1376
1615
 
1377
- # Hit non-@import content, stop skipping
1378
- 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
1379
1651
  end
1652
+
1653
+ result
1380
1654
  end
1381
1655
 
1382
1656
  # Parse a block of declarations given start/end positions
@@ -1388,7 +1662,7 @@ module Cataract
1388
1662
 
1389
1663
  while pos < end_pos
1390
1664
  # Skip whitespace
1391
- while pos < end_pos && whitespace?(@css.getbyte(pos))
1665
+ while pos < end_pos && whitespace?(@_css.getbyte(pos))
1392
1666
  pos += 1
1393
1667
  end
1394
1668
  break if pos >= end_pos
@@ -1400,5 +1674,115 @@ module Cataract
1400
1674
 
1401
1675
  declarations
1402
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
1403
1787
  end
1404
1788
  end